fix: time grid layout alignment and task drag/drop collision detection

- Removed boxed background from grid tasks and added hover-only resize handle

- Fixed vertical drop calculation using correct cell sizes

- Aligned time column header perfectly using a structural ghost replica

- Added collision detection to prevent dragging tasks over occupied slots
This commit is contained in:
mARTin 2026-02-23 21:29:56 +01:00
parent 999b354cc8
commit f9d578ddf1
23 changed files with 2097 additions and 889 deletions

View File

@ -7,6 +7,7 @@ Dockerfile
docker-compose*.yml
.env
.env.*
!.env.example
.planning
prisma/dev.db
*.md

43
.env.example Normal file
View File

@ -0,0 +1,43 @@
# ==============================================================================
# My Weekly ToDo List - Environment Variables
# ==============================================================================
# Copy this file to .env.docker (production), .env.docker.dev (development),
# or .env.docker.local (local) and fill in your actual values.
# NEVER commit files containing real credentials to version control.
# ==============================================================================
# --- Database ---
# PostgreSQL connection string (Docker service name "db" as host)
DATABASE_URL=postgresql://YOUR_DB_USER:YOUR_DB_PASSWORD@db:5432/My-Weekly-ToDo-List?schema=public
POSTGRES_DB=My-Weekly-ToDo-List
POSTGRES_USER=YOUR_DB_USER
POSTGRES_PASSWORD=YOUR_DB_PASSWORD
# --- NextAuth ---
# Generate a secret: openssl rand -base64 32
NEXTAUTH_SECRET=YOUR_NEXTAUTH_SECRET
# The public URL where the app is accessible
NEXTAUTH_URL=https://your-domain.com
NEXT_PUBLIC_BASE_URL=https://your-domain.com
# --- SMTP (Email) ---
# Required for email verification and password reset
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-email@example.com
SMTP_PASSWORD=YOUR_SMTP_PASSWORD
SMTP_SECURE=true
# --- Google OAuth (optional) ---
# Required for Google Calendar and Google Tasks integration
# Set up at: https://console.cloud.google.com/apis/credentials
GOOGLE_CLIENT_ID=YOUR_GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET=YOUR_GOOGLE_CLIENT_SECRET
GOOGLE_REDIRECT_URI=https://your-domain.com/api/calendar/google/oauth
# --- Microsoft OAuth (optional) ---
# Required for Outlook Calendar and Microsoft To-Do integration
# Set up at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps
MICROSOFT_CLIENT_ID=YOUR_MICROSOFT_CLIENT_ID
MICROSOFT_CLIENT_SECRET=YOUR_MICROSOFT_CLIENT_SECRET
MICROSOFT_REDIRECT_URI=https://your-domain.com/api/calendar/outlook/callback

8
.gitignore vendored
View File

@ -15,6 +15,10 @@ Inspiration/
.env
.env.local
.env.production
.env.development
.env.docker
.env.docker.dev
.env.docker.local
# Logs and temp files
*.log
@ -29,6 +33,10 @@ node_modules/
/playwright/.cache/
/playwright/.auth/
# Development database
prisma/dev.db
prisma/dev.db-journal
# Next.js
.next/

View File

@ -5,10 +5,8 @@ services:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://root:WKE7xeZohxdZit7eObjG@db:5432/My-Weekly-ToDo-List?schema=public
- NEXTAUTH_SECRET=Ca7EoJzZSMJO2CkqwdWd
- NEXT_PUBLIC_BASE_URL=http://localhost:3000
env_file:
- .env.docker.dev
depends_on:
- db
volumes:
@ -18,10 +16,8 @@ services:
db:
image: postgres:15
environment:
POSTGRES_DB: My-Weekly-ToDo-List
POSTGRES_USER: root
POSTGRES_PASSWORD: WKE7xeZohxdZit7eObjG
env_file:
- .env.docker.dev
volumes:
- postgres_data:/var/lib/postgresql/data
ports:

View File

@ -5,10 +5,8 @@ services:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://root:WKE7xeZohxdZit7eObjG@db:5432/My-Weekly-ToDo-List?schema=public
- NEXTAUTH_SECRET=Ca7EoJzZSMJO2CkqwdWd
- NEXT_PUBLIC_BASE_URL=https://todo.martin-bierschenk.de
env_file:
- .env.docker.local
depends_on:
- db
volumes:
@ -16,10 +14,8 @@ services:
db:
image: postgres:15
environment:
POSTGRES_DB: My-Weekly-ToDo-List
POSTGRES_USER: root
POSTGRES_PASSWORD: WKE7xeZohxdZit7eObjG
env_file:
- .env.docker.local
volumes:
- postgres_data:/var/lib/postgresql/data
ports:

View File

@ -5,22 +5,8 @@ services:
image: my-weekly-todo:latest
ports:
- "13000:3000"
environment:
- DATABASE_URL=postgresql://root:WKE7xeZohxdZit7eObjG@db:5432/My-Weekly-ToDo-List?schema=public
- NEXTAUTH_SECRET=Ca7EoJzZSMJO2CkqwdWd
- NEXTAUTH_URL=https://todo.martin-bierschenk.de
- NEXT_PUBLIC_BASE_URL=https://todo.martin-bierschenk.de
- SMTP_HOST=w00ff033.kasserver.com
- SMTP_PORT=587
- SMTP_USERNAME=mail@carrylight.de
- SMTP_PASSWORD=QijU8e2A8p3FE8WS8esR
- SMTP_SECURE=true
- GOOGLE_CLIENT_ID=196368743757-1fn17q2ecg5n8rej4tu96khltno173he.apps.googleusercontent.com
- GOOGLE_CLIENT_SECRET=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
- GOOGLE_REDIRECT_URI=https://todo.martin-bierschenk.de/api/calendar/google/oauth
- MICROSOFT_CLIENT_ID=a6ea3972-2e4a-4a24-b50f-4047867d8de3
- MICROSOFT_CLIENT_SECRET=9649da1a-9057-4ac5-ad5a-73cf1cef9478
- MICROSOFT_REDIRECT_URI=https://todo.martin-bierschenk.de/api/calendar/outlook/callback
env_file:
- .env.docker
depends_on:
- db
volumes:
@ -29,10 +15,8 @@ services:
db:
image: postgres:15
environment:
POSTGRES_DB: My-Weekly-ToDo-List
POSTGRES_USER: root
POSTGRES_PASSWORD: WKE7xeZohxdZit7eObjG
env_file:
- .env.docker
volumes:
- postgres_data:/var/lib/postgresql/data
ports:

View File

@ -10,7 +10,7 @@ datasource db {
model User {
id String @id @default(cuid())
email String @unique
passwordHash String? // Optional for SSO users
passwordHash String?
name String?
image String?
emailVerified DateTime?
@ -40,13 +40,12 @@ model User {
cellDuration Int @default(30)
viewStyle String @default("grid")
viewDays Int @default(7)
fontSize String @default("M") // "S", "M", "L"
goalFallbackType String @default("quote") // "quote" | "next_todo" | "default"
fontSize String @default("M")
goalFallbackType String @default("quote")
goalDefaultSentence String @default("goal of the week")
goalFontFamily String? @default("Inter")
goalFontSize String? @default("0.9rem")
goalFontWeight String? @default("500")
goalScope String @default("week") // "week" | "day"
headlineFont String @default("Inter")
headlineFontSize String? @default("1.25rem")
headlineFontWeight String? @default("900")
@ -63,24 +62,34 @@ model User {
eventFontFamily String? @default("Inter")
eventFontSize String? @default("0.85rem")
eventFontWeight String? @default("400")
fontWeight String @default("400") // Legacy/Generic
// Color Settings
fontWeight String @default("400")
weekdayColor String? @default("#888888")
dateColor String? @default("#888888")
taskColor String? @default("#333333")
todayHighlightColor String? @default("#f0fafa")
weekendColorSat String? @default("#666666")
weekendColorSun String? @default("#dc2626")
pastDayColor String? @default("#a6a6a7")
goalScope String @default("week")
dateLayout String @default("right")
dateAlignment String @default("center")
hourLabelFormat String @default("short")
showSubHourSlots Boolean @default(true)
allDayPosition String @default("below")
cwColor String? @default("#333333")
cwFontFamily String? @default("Inter")
cwFontSize String? @default("1.125rem")
cwFontWeight String? @default("700")
yearColor String? @default("#333333")
yearFontFamily String? @default("Inter")
yearFontSize String? @default("1.125rem")
yearFontWeight String? @default("700")
accounts Account[]
sessions Session[]
tasks Task[]
somedayLists SomedayList[]
calendarConnections CalendarConnection[]
cachedCalendarEvents CachedCalendarEvent[]
calendarConnections CalendarConnection[]
sessions Session[]
somedayLists SomedayList[]
tasks Task[]
weeklyGoals WeeklyGoal[]
}
@ -90,14 +99,13 @@ model Account {
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@ -124,40 +132,40 @@ model Task {
userId String
title String
description String?
markdownContent String? @db.Text
markdownContent String?
completed Boolean @default(false)
isRolling Boolean @default(false)
order Int @default(0)
dayOfWeek Int? // 0-6 for Sunday-Saturday (legacy/someday lists)
scheduledDate DateTime? // Actual date for the task
dayOfWeek Int?
scheduledDate DateTime?
somedayListId String?
originalDate DateTime?
startTime String?
endTime String?
isRecurring Boolean @default(false)
recurrenceInterval Int? // Number of units between occurrences
recurrenceUnit String? // "days" or "weeks"
recurrenceTime String? // e.g. "09:00" - time for the recurring task
recurrenceEndDate DateTime? // Optional end date for recurrence
deletedAt DateTime? // Soft delete - null means active, set means trashed
recurrenceInterval Int?
recurrenceUnit String?
recurrenceTime String?
recurrenceEndDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
somedayList SomedayList? @relation(fields: [somedayListId], references: [id], onDelete: SetNull)
// External Integration
externalId String?
externalProvider String? // "google" | "apple" | "outlook"
externalProvider String?
externalListId String?
lastSyncedAt DateTime?
deletedAt DateTime?
parentTaskId String?
parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade)
subTasks Task[] @relation("SubTasks")
somedayList SomedayList? @relation(fields: [somedayListId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, dayOfWeek])
@@index([userId, scheduledDate])
@@index([userId, somedayListId])
@@index([userId, externalId])
@@index([userId, deletedAt])
@@index([parentTaskId])
}
model SomedayList {
@ -167,7 +175,9 @@ model SomedayList {
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
externalId String?
externalProvider String?
lastSyncedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tasks Task[]
@ -181,7 +191,7 @@ model CalendarConnection {
accessToken String
refreshToken String?
expiresAt DateTime?
calendars Json? // Stores array of { id, title, isPrimary, selected }
calendars Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cachedEvents CachedCalendarEvent[]
@ -193,24 +203,23 @@ model CachedCalendarEvent {
userId String
externalId String
connectionId String
provider String // "google" | "apple" | "outlook"
provider String
calendarId String
calendarTitle String
calendarColor String?
title String
description String? @db.Text
description String?
location String?
startDateTime DateTime?
startDate String? // YYYY-MM-DD for all-day events
startDate String?
endDateTime DateTime?
endDate String? // YYYY-MM-DD for all-day events
endDate String?
weekStart DateTime
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, externalId, provider])
@@index([userId, startDateTime])

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { createGoogleClient, createGoogleTaskList } from '@/lib/google-tasks';
import { createMsTodoList } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
import { createAppleReminderList } from '@/lib/apple-reminders';
const prisma = new PrismaClient();
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { title, provider } = body;
if (!title) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email }
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
let externalId: string | undefined;
let externalProvider: string | undefined;
if (provider === 'google') {
const account = await prisma.account.findFirst({
where: { userId: user.id, provider: 'google' }
});
if (!account || !account.access_token) {
return NextResponse.json({ error: 'Google account not connected' }, { status: 400 });
}
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
const newList = await createGoogleTaskList(client, title);
externalId = newList.id;
externalProvider = 'google';
} else if (provider === 'outlook' || provider === 'microsoft') {
const accessToken = await getOutlookAccessToken(user.id);
if (!accessToken) {
return NextResponse.json({ error: 'Outlook account not connected' }, { status: 400 });
}
const newList = await createMsTodoList(accessToken, title);
externalId = newList.id;
externalProvider = 'outlook';
} else if (provider === 'apple') {
const connection = await prisma.calendarConnection.findFirst({
where: { userId: user.id, provider: 'apple' }
});
if (!connection || !connection.accessToken) {
return NextResponse.json({ error: 'Apple account not connected' }, { status: 400 });
}
const [email, password] = connection.accessToken.split(':');
const newList = await createAppleReminderList(email, password, title);
externalId = newList.id;
externalProvider = 'apple';
}
// Create local SomedayList
console.log('Creating SomedayList with data:', {
userId: user.id,
title,
externalId: externalId ?? null,
externalProvider: externalProvider ?? null,
});
const somedayList = await prisma.somedayList.create({
data: {
userId: user.id,
title,
order: 0, // In external route we can just default to 0 or calculate it
externalId: externalId ?? null,
externalProvider: externalProvider ?? null,
lastSyncedAt: externalId ? new Date() : null
}
});
console.log('Successfully created SomedayList:', somedayList.id);
return NextResponse.json({ success: true, somedayList });
} catch (error: any) {
console.error('Error creating external someday list:', error);
return NextResponse.json(
{ error: error.message || 'Failed to create list' },
{ status: 500 }
);
}
}

View File

@ -4,7 +4,7 @@ import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { createGoogleClient, fetchGoogleTasks, fetchGoogleTaskLists } from '@/lib/google-tasks';
import { fetchMsTodoLists, fetchMsTodoTasks, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
import { fetchMsTodoLists, fetchMsTodoTasks, isMsTodoTaskCompleted, fetchMsChecklistItems } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
const prisma = new PrismaClient();
@ -22,6 +22,7 @@ interface ImportedTask {
dueDate: Date | null;
status: string;
sourceListTitle: string;
parentExternalId?: string;
}
export async function POST(req: NextRequest) {
@ -57,6 +58,7 @@ export async function POST(req: NextRequest) {
}
const importedTasks: ImportedTask[] = [];
let targetLists: SourceList[] = lists;
if (provider === 'google') {
const account = await prisma.account.findFirst({
@ -69,7 +71,6 @@ export async function POST(req: NextRequest) {
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
let targetLists = lists;
if (targetLists.length === 0) {
const googleLists = await fetchGoogleTaskLists(client);
if (googleLists.length > 0) {
@ -78,6 +79,7 @@ export async function POST(req: NextRequest) {
}
for (const sourceList of targetLists) {
try {
const googleTasks = await fetchGoogleTasks(client, sourceList.id);
importedTasks.push(...googleTasks.map(t => ({
title: t.title,
@ -87,9 +89,12 @@ export async function POST(req: NextRequest) {
dueDate: t.due ? new Date(t.due) : null,
status: t.status,
sourceListTitle: sourceList.title,
parentExternalId: t.parent || undefined,
})));
} catch (e) {
console.error(`Error fetching Google tasks for list ${sourceList.id}:`, e);
}
}
}
if (provider === 'outlook') {
@ -98,7 +103,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Outlook account not connected' }, { status: 400 });
}
let targetLists = lists;
if (targetLists.length === 0) {
const msTodoLists = await fetchMsTodoLists(accessToken);
if (msTodoLists.length > 0) {
@ -108,8 +112,10 @@ export async function POST(req: NextRequest) {
}
for (const sourceList of targetLists) {
try {
const msTasks = await fetchMsTodoTasks(accessToken, sourceList.id);
importedTasks.push(...msTasks.map(t => ({
for (const t of msTasks) {
importedTasks.push({
title: t.title,
description: t.body?.content || '',
externalId: t.id,
@ -117,12 +123,40 @@ export async function POST(req: NextRequest) {
dueDate: t.dueDateTime ? new Date(t.dueDateTime.dateTime) : null,
status: isMsTodoTaskCompleted(t.status) ? 'completed' : 'notStarted',
sourceListTitle: sourceList.title,
})));
});
// Fetch checklist items as sub-tasks
try {
const checklistItems = await fetchMsChecklistItems(accessToken, sourceList.id, t.id);
for (const item of checklistItems) {
importedTasks.push({
title: item.displayName,
description: '',
externalId: item.id,
externalListId: sourceList.id,
dueDate: null,
status: item.isChecked ? 'completed' : 'notStarted',
sourceListTitle: sourceList.title,
parentExternalId: t.id,
});
}
} catch (e) {
console.error(`Error fetching checklist items for task ${t.id}:`, e);
}
}
} catch (e) {
console.error(`Error fetching MS Todo tasks for list ${sourceList.id}:`, e);
}
}
}
// Group tasks by source list title
const tasksByList = new Map<string, ImportedTask[]>();
// Pre-initialize with all target lists to ensure they are created even if empty
for (const tl of targetLists) {
tasksByList.set(tl.title, []);
}
for (const task of importedTasks) {
const listTitle = task.sourceListTitle;
if (!tasksByList.has(listTitle)) {
@ -137,6 +171,11 @@ export async function POST(req: NextRequest) {
let updatedCount = 0;
let listsCreated = 0;
// Track externalId -> local task ID for parent-child linking
const externalToLocalId = new Map<string, string>();
// Tasks that need parent linking after creation
const pendingParentLinks: { localId: string; parentExternalId: string }[] = [];
for (const [listTitle, tasks] of tasksByList) {
let somedayList = await prisma.somedayList.findFirst({
where: { userId: user.id, title: listTitle }
@ -150,7 +189,15 @@ export async function POST(req: NextRequest) {
console.log(`[IMPORT] Created SomedayList "${listTitle}" (${somedayList.id})`);
}
for (const task of tasks) {
// First pass: create/update all tasks (parents first via sorting)
const sortedTasks = [...tasks].sort((a, b) => {
// Parent tasks (no parentExternalId) come first
if (!a.parentExternalId && b.parentExternalId) return -1;
if (a.parentExternalId && !b.parentExternalId) return 1;
return 0;
});
for (const task of sortedTasks) {
const existingTask = await prisma.task.findFirst({
where: { userId: user.id, externalId: task.externalId, externalProvider: provider }
});
@ -163,9 +210,13 @@ export async function POST(req: NextRequest) {
lastSyncedAt: new Date()
}
});
externalToLocalId.set(task.externalId, existingTask.id);
if (task.parentExternalId) {
pendingParentLinks.push({ localId: existingTask.id, parentExternalId: task.parentExternalId });
}
updatedCount++;
} else {
await prisma.task.create({
const newTask = await prisma.task.create({
data: {
userId: user.id,
title: task.title,
@ -178,11 +229,26 @@ export async function POST(req: NextRequest) {
lastSyncedAt: new Date()
}
});
externalToLocalId.set(task.externalId, newTask.id);
if (task.parentExternalId) {
pendingParentLinks.push({ localId: newTask.id, parentExternalId: task.parentExternalId });
}
count++;
}
}
}
// Second pass: link parent-child relationships
for (const { localId, parentExternalId } of pendingParentLinks) {
const parentLocalId = externalToLocalId.get(parentExternalId);
if (parentLocalId) {
await prisma.task.update({
where: { id: localId },
data: { parentTaskId: parentLocalId }
});
}
}
console.log(`[IMPORT] Done! Created: ${count}, Updated: ${updatedCount}, Lists created: ${listsCreated}`);
return NextResponse.json({ success: true, count, updatedCount, listsCreated });

View File

@ -132,13 +132,19 @@ export async function GET(request: NextRequest) {
const start = searchParams.get('start');
const end = searchParams.get('end');
// Fetch REAL tasks (exclude soft-deleted)
// Fetch REAL tasks (exclude soft-deleted), include sub-tasks
const includeDeleted = searchParams.get('includeDeleted') === 'true';
const tasks = await prisma.task.findMany({
where: {
userId,
...(includeDeleted ? {} : { deletedAt: null }),
},
include: {
subTasks: {
where: includeDeleted ? {} : { deletedAt: null },
orderBy: { order: 'asc' },
},
},
orderBy: [
{ dayOfWeek: 'asc' },
{ order: 'asc' },
@ -189,7 +195,7 @@ export async function POST(request: NextRequest) {
const userId = (session.user as any).id;
const body = await request.json();
const { title, description, dayOfWeek, order, markdownContent, somedayListId, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate } = body;
const { title, description, dayOfWeek, order, markdownContent, somedayListId, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, parentTaskId } = body;
let { isRolling } = body;
const { isRecurring } = body;
@ -225,7 +231,8 @@ export async function POST(request: NextRequest) {
recurrenceInterval: recurrenceInterval ? parseInt(recurrenceInterval) : null,
recurrenceUnit,
recurrenceTime,
recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null
recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null,
parentTaskId: parentTaskId || null,
},
});
@ -258,7 +265,7 @@ export async function PATCH(request: NextRequest) {
const body = await request.json();
const { id } = body;
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore } = body;
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore, parentTaskId } = body;
if (!id) {
return NextResponse.json(
@ -341,7 +348,8 @@ export async function PATCH(request: NextRequest) {
...(recurrenceUnit !== undefined && { recurrenceUnit }),
...(recurrenceTime !== undefined && { recurrenceTime }),
...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }),
...(restore === true && { deletedAt: null })
...(restore === true && { deletedAt: null }),
...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null })
},
});

View File

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync } from '@/lib/google-tasks';
import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync, GoogleTask } from '@/lib/google-tasks';
import { fetchMsTodoTasksForSync, updateMsTodoTask, deleteMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
@ -33,14 +33,27 @@ export async function GET(req: NextRequest) {
}
});
// Find all synced SomedayLists (for discovering new remote tasks)
const syncedLists = await prisma.somedayList.findMany({
where: {
userId: user.id,
externalId: { not: null },
externalProvider: { not: null },
}
});
const googleLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'google');
const outlookLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'outlook');
let updated = 0;
let deleted = 0;
let created = 0;
// --- Google Tasks pull-sync ---
if (googleLocalTasks.length > 0) {
const googleSyncedLists = syncedLists.filter(l => l.externalProvider === 'google');
const hasGoogleTasks = googleLocalTasks.length > 0 || googleSyncedLists.length > 0;
if (hasGoogleTasks) {
const account = await prisma.account.findFirst({
where: { userId: user.id, provider: 'google' }
});
@ -48,6 +61,21 @@ export async function GET(req: NextRequest) {
if (account?.access_token) {
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
// Build set of all Google list IDs to sync (from tasks + synced lists)
const googleListIds = new Set<string>();
const listIdToSomedayList = new Map<string, { id: string; title: string }>();
for (const task of googleLocalTasks) {
if (task.externalListId) googleListIds.add(task.externalListId);
}
for (const sl of googleSyncedLists) {
if (sl.externalId) {
googleListIds.add(sl.externalId);
listIdToSomedayList.set(sl.externalId, { id: sl.id, title: sl.title });
}
}
// Group existing local tasks by list
const googleByList = new Map<string, typeof googleLocalTasks>();
for (const task of googleLocalTasks) {
if (!task.externalListId) continue;
@ -57,12 +85,23 @@ export async function GET(req: NextRequest) {
googleByList.get(task.externalListId)!.push(task);
}
for (const [listId, tasks] of googleByList) {
for (const listId of googleListIds) {
const localTasks = googleByList.get(listId) || [];
try {
const remoteTasks = await fetchGoogleTasksForSync(client, listId);
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
for (const localTask of tasks) {
// Build externalId -> localId map for parent linking
const extToLocalMap = new Map<string, string>();
for (const t of localTasks) {
if (t.externalId) extToLocalMap.set(t.externalId, t.id);
}
// Build set of existing external IDs for quick lookup
const existingExternalIds = new Set(localTasks.map(t => t.externalId));
// Update/delete existing local tasks
for (const localTask of localTasks) {
const remote = remoteMap.get(localTask.externalId!);
if (!remote) {
@ -93,6 +132,16 @@ export async function GET(req: NextRequest) {
updateData.description = remote.notes || null;
}
// Sync parent relationship from Google Tasks
if (remote.parent) {
const parentLocalId = extToLocalMap.get(remote.parent);
if (parentLocalId && localTask.parentTaskId !== parentLocalId) {
updateData.parentTaskId = parentLocalId;
}
} else if (localTask.parentTaskId && !remote.parent) {
updateData.parentTaskId = null;
}
if (Object.keys(updateData).length > 1) {
await prisma.task.update({
where: { id: localTask.id },
@ -106,6 +155,44 @@ export async function GET(req: NextRequest) {
});
}
}
// Create new local tasks for remote tasks not yet in local DB
const somedayListInfo = listIdToSomedayList.get(listId);
if (somedayListInfo) {
// Sort: parents first, then children
const newRemoteTasks = remoteTasks
.filter(rt => !existingExternalIds.has(rt.id))
.sort((a, b) => {
if (!a.parent && b.parent) return -1;
if (a.parent && !b.parent) return 1;
return 0;
});
for (const remote of newRemoteTasks) {
// Skip empty-title tasks
if (!remote.title || !remote.title.trim()) continue;
const parentLocalId = remote.parent ? extToLocalMap.get(remote.parent) : undefined;
const newTask = await prisma.task.create({
data: {
userId: user.id,
title: remote.title,
description: remote.notes || null,
completed: remote.status === 'completed',
somedayListId: somedayListInfo.id,
externalId: remote.id,
externalProvider: 'google',
externalListId: listId,
parentTaskId: parentLocalId || null,
lastSyncedAt: new Date(),
}
});
extToLocalMap.set(remote.id, newTask.id);
existingExternalIds.add(remote.id);
created++;
}
}
} catch (listError) {
console.error(`Error syncing Google list ${listId}:`, listError);
}
@ -114,10 +201,28 @@ export async function GET(req: NextRequest) {
}
// --- Microsoft To-Do pull-sync ---
if (outlookLocalTasks.length > 0) {
const outlookSyncedLists = syncedLists.filter(l => l.externalProvider === 'outlook');
const hasOutlookTasks = outlookLocalTasks.length > 0 || outlookSyncedLists.length > 0;
if (hasOutlookTasks) {
const outlookToken = await getOutlookAccessToken(user.id);
if (outlookToken) {
// Build set of all Outlook list IDs to sync
const outlookListIds = new Set<string>();
const outlookListIdToSomedayList = new Map<string, { id: string; title: string }>();
for (const task of outlookLocalTasks) {
if (task.externalListId) outlookListIds.add(task.externalListId);
}
for (const sl of outlookSyncedLists) {
if (sl.externalId) {
outlookListIds.add(sl.externalId);
outlookListIdToSomedayList.set(sl.externalId, { id: sl.id, title: sl.title });
}
}
// Group existing local tasks by list
const outlookByList = new Map<string, typeof outlookLocalTasks>();
for (const task of outlookLocalTasks) {
if (!task.externalListId) continue;
@ -127,12 +232,15 @@ export async function GET(req: NextRequest) {
outlookByList.get(task.externalListId)!.push(task);
}
for (const [listId, tasks] of outlookByList) {
for (const listId of outlookListIds) {
const localTasks = outlookByList.get(listId) || [];
try {
const remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
for (const localTask of tasks) {
const existingExternalIds = new Set(localTasks.map(t => t.externalId));
for (const localTask of localTasks) {
const remote = remoteMap.get(localTask.externalId!);
if (!remote) {
@ -177,6 +285,32 @@ export async function GET(req: NextRequest) {
});
}
}
// Create new local tasks for remote tasks not yet in local DB
const somedayListInfo = outlookListIdToSomedayList.get(listId);
if (somedayListInfo) {
const newRemoteTasks = remoteTasks.filter(rt => !existingExternalIds.has(rt.id));
for (const remote of newRemoteTasks) {
if (!remote.title || !remote.title.trim()) continue;
await prisma.task.create({
data: {
userId: user.id,
title: remote.title,
description: remote.body?.content || null,
completed: isMsTodoTaskCompleted(remote.status),
somedayListId: somedayListInfo.id,
externalId: remote.id,
externalProvider: 'outlook',
externalListId: listId,
lastSyncedAt: new Date(),
}
});
existingExternalIds.add(remote.id);
created++;
}
}
} catch (listError) {
console.error(`Error syncing Outlook list ${listId}:`, listError);
}
@ -184,7 +318,7 @@ export async function GET(req: NextRequest) {
}
}
return NextResponse.json({ success: true, updated, deleted });
return NextResponse.json({ success: true, updated, deleted, created });
} catch (error: unknown) {
console.error('Pull sync error:', error);

View File

@ -40,6 +40,7 @@ export async function GET(request: NextRequest) {
goalFontSize: true,
goalFontWeight: true,
goalScope: true,
dateLayout: true,
headlineFont: true,
headlineFontSize: true,
headlineFontWeight: true,
@ -64,6 +65,17 @@ export async function GET(request: NextRequest) {
weekendColorSat: true,
weekendColorSun: true,
pastDayColor: true,
hourLabelFormat: true,
showSubHourSlots: true,
allDayPosition: true,
cwFontFamily: true,
cwFontSize: true,
cwFontWeight: true,
cwColor: true,
yearFontFamily: true,
yearFontSize: true,
yearFontWeight: true,
yearColor: true,
createdAt: true
}
});
@ -99,7 +111,11 @@ export async function PATCH(request: NextRequest) {
fontWeight, weekendColorSat, weekendColorSun,
weekdayColor, dateColor, taskColor, todayHighlightColor,
pastDayColor, goalFallbackType, goalDefaultSentence,
goalFontFamily, goalFontSize, goalFontWeight, goalScope
goalFontFamily, goalFontSize, goalFontWeight, goalScope,
dateLayout,
hourLabelFormat, showSubHourSlots, allDayPosition,
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
yearFontFamily, yearFontSize, yearFontWeight, yearColor
} = body;
const updateData: any = {
@ -154,6 +170,18 @@ export async function PATCH(request: NextRequest) {
...(goalFontSize !== undefined && { goalFontSize }),
...(goalFontWeight !== undefined && { goalFontWeight }),
...(goalScope !== undefined && { goalScope }),
...(dateLayout !== undefined && { dateLayout }),
...(hourLabelFormat !== undefined && { hourLabelFormat }),
...(showSubHourSlots !== undefined && { showSubHourSlots }),
...(allDayPosition !== undefined && { allDayPosition }),
...(cwFontFamily !== undefined && { cwFontFamily }),
...(cwFontSize !== undefined && { cwFontSize }),
...(cwFontWeight !== undefined && { cwFontWeight }),
...(cwColor !== undefined && { cwColor }),
...(yearFontFamily !== undefined && { yearFontFamily }),
...(yearFontSize !== undefined && { yearFontSize }),
...(yearFontWeight !== undefined && { yearFontWeight }),
...(yearColor !== undefined && { yearColor }),
};
if (password && password.trim() !== "") {
updateData.passwordHash = await bcrypt.hash(password, 10);
@ -210,12 +238,24 @@ export async function PATCH(request: NextRequest) {
weekendColorSat: true,
weekendColorSun: true,
pastDayColor: true,
hourLabelFormat: true,
showSubHourSlots: true,
allDayPosition: true,
goalFallbackType: true,
goalDefaultSentence: true,
goalFontFamily: true,
goalFontSize: true,
goalFontWeight: true,
goalScope: true,
dateLayout: true,
cwFontFamily: true,
cwFontSize: true,
cwFontWeight: true,
cwColor: true,
yearFontFamily: true,
yearFontSize: true,
yearFontWeight: true,
yearColor: true,
}
});

View File

@ -1701,28 +1701,12 @@ h3 {
position: relative;
}
/* Current time label in time column */
.now-time-label {
position: absolute;
right: 2px;
transform: translateY(-50%);
font-size: 10px;
font-weight: 700;
color: #d50000;
background: var(--weekly-bg, #fff);
padding: 0 2px;
z-index: 12;
line-height: 1;
letter-spacing: -0.02em;
white-space: nowrap;
pointer-events: none;
}
.time-slot-label {
display: flex;
align-items: center; /* Changed from flex-start to center */
align-items: flex-start; /* Aligns to top so translateY(-50%) centers on the line */
justify-content: flex-end;
padding: 0 0.5rem; /* Removed top padding */
padding: 0 0.5rem;
font-size: 0.65rem;
color: var(--weekly-text-light);
box-sizing: border-box;
@ -2023,7 +2007,7 @@ h3 {
.now-line {
position: absolute;
left: 0;
right: 0;
right: 40px;
height: 2px;
background: #d50000;
z-index: 10;
@ -2031,7 +2015,19 @@ h3 {
}
.now-line::before {
content: none;
content: attr(data-time);
position: absolute;
right: -40px;
padding-right: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 10px;
font-weight: 700;
color: #d50000;
line-height: 1;
letter-spacing: -0.02em;
white-space: nowrap;
z-index: 12;
}
.now-line::after {
@ -2940,3 +2936,16 @@ h3 {
max-width: 100vw;
}
}
/* Grid Task Block Resize Handle */
.time-slot-task .task-resize-handle {
opacity: 0;
transition: opacity 0.2s ease;
}
.time-slot-task:hover .task-resize-handle,
.time-slot-task .task-resize-handle.active {
opacity: 1;
}

View File

@ -0,0 +1,417 @@
import React, { useState, useRef, useEffect } from "react";
import { Repeat, ChevronDown, ChevronRight, FileText, CheckCircle, Circle, Copy } from "lucide-react";
import { Task } from "./WeeklyView";
interface GridTaskBlockProps {
task: Task;
date: Date;
activeDate: Date;
cellDuration: number;
darkMode: boolean;
isProtected: boolean;
editingTaskId: string | null;
setEditingTaskId: (id: string | null) => void;
updateTask: (id: string, title: string) => void;
updateTaskNotes: (id: string, notes: string) => void;
updateTaskDuration: (id: string, duration: number) => void;
toggleTask: (id: string) => void;
deleteTask: (id: string) => void;
toggleTaskRolling: (id: string) => void;
setSelectedTaskForNotes: (task: Task) => void;
setSelectedTaskForRecurrence: (task: Task) => void;
handleDragStart: (e: React.DragEvent, task: Task) => void;
handleDragEnd: (e: React.DragEvent) => void;
getSlotHeight: (duration: number) => number;
draggedTask: Task | null;
addSubTask: (parentId: string, title: string) => void;
toggleSubTask: (id: string) => void;
updateSubTask: (id: string, title: string) => void;
deleteSubTask: (id: string) => void;
onSetEditingTaskId?: (id: string | null) => void;
workingHoursStart: number;
}
export function GridTaskBlock({
task,
date,
activeDate,
cellDuration,
darkMode,
isProtected,
editingTaskId,
setEditingTaskId,
updateTask,
updateTaskNotes,
updateTaskDuration,
toggleTask,
deleteTask,
toggleTaskRolling,
setSelectedTaskForNotes,
setSelectedTaskForRecurrence,
handleDragStart,
handleDragEnd,
getSlotHeight,
draggedTask,
addSubTask,
toggleSubTask,
updateSubTask,
deleteSubTask,
onSetEditingTaskId,
workingHoursStart
}: GridTaskBlockProps) {
const [isNotesOpen, setIsNotesOpen] = useState(false);
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
const [isSubTasksOpen, setIsSubTasksOpen] = useState(false);
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
const notesRef = useRef<HTMLTextAreaElement>(null);
const subTaskInputRef = useRef<HTMLInputElement>(null);
// Resize State
const [isResizing, setIsResizing] = useState(false);
const [resizeHeight, setResizeHeight] = useState<number | null>(null);
const resizeStartY = useRef<number>(0);
const resizeStartHeight = useRef<number>(0);
// Focus notes when opened
useEffect(() => {
if (isNotesOpen && notesRef.current) {
notesRef.current.focus();
}
}, [isNotesOpen]);
const handleNotesBlur = () => {
if (notesValue !== task.markdownContent) {
updateTaskNotes(task.id, notesValue);
}
};
const insertMarkdown = (prefix: string, suffix: string = "") => {
if (!notesRef.current) return;
const start = notesRef.current.selectionStart;
const end = notesRef.current.selectionEnd;
const text = notesValue;
const before = text.substring(0, start);
const selection = text.substring(start, end);
const after = text.substring(end);
const newText = `${before}${prefix}${selection}${suffix}${after}`;
setNotesValue(newText);
setTimeout(() => {
if (notesRef.current) {
notesRef.current.focus();
const newCursorPos = start + prefix.length + selection.length + suffix.length;
notesRef.current.setSelectionRange(newCursorPos, newCursorPos);
}
}, 0);
};
if (!task.startTime) return null;
const [startHour, startMinute] = task.startTime.split(":").map(Number);
const startMinutes = (startHour - workingHoursStart) * 60 + startMinute;
// Calculate top offset
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
const topOffset = startMinutes * pixelsPerMinute;
// Calculate height
const duration = task.duration || 15; // default 15m if not set
const baseHeight = duration * pixelsPerMinute;
const currentHeight = isResizing && resizeHeight !== null ? resizeHeight : baseHeight;
// Handlers for resizing
const onResizeStart = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setIsResizing(true);
resizeStartY.current = e.clientY;
resizeStartHeight.current = baseHeight;
document.body.style.cursor = "ns-resize";
};
useEffect(() => {
const onResizeMove = (e: MouseEvent) => {
if (!isResizing) return;
const deltaY = e.clientY - resizeStartY.current;
let newHeight = resizeStartHeight.current + deltaY;
const minHeight = 15 * pixelsPerMinute; // min 15 mins
if (newHeight < minHeight) newHeight = minHeight;
setResizeHeight(newHeight);
};
const onResizeEnd = (e: MouseEvent) => {
if (!isResizing) return;
setIsResizing(false);
document.body.style.cursor = "";
if (resizeHeight !== null) {
const newDurationMins = Math.round(resizeHeight / pixelsPerMinute / 15) * 15;
updateTaskDuration(task.id, newDurationMins);
}
setResizeHeight(null);
};
if (isResizing) {
window.addEventListener("mousemove", onResizeMove);
window.addEventListener("mouseup", onResizeEnd);
}
return () => {
window.removeEventListener("mousemove", onResizeMove);
window.removeEventListener("mouseup", onResizeEnd);
};
}, [isResizing, resizeHeight, pixelsPerMinute, task.id, updateTaskDuration]);
return (
<div
className={`time-slot-task ${task.completed ? "completed" : ""} ${draggedTask?.id === task.id ? "dragging" : ""}`}
style={{
position: "absolute",
top: `${topOffset}px`,
left: 0,
right: 0,
minHeight: `${Math.max(currentHeight, 20)}px`,
height: isNotesOpen || isSubTasksOpen ? "auto" : `${currentHeight}px`,
zIndex: isResizing || isNotesOpen || isSubTasksOpen ? 10 : 5,
background: (isNotesOpen || isSubTasksOpen || isResizing) ? (darkMode ? "#2a2a2a" : "#ffffff") : "transparent",
border: (isNotesOpen || isSubTasksOpen || isResizing) ? `1px solid ${darkMode ? "#404040" : "#e0e0e0"}` : "none",
borderRadius: (isNotesOpen || isSubTasksOpen || isResizing) ? "4px" : "0",
padding: "2px 4px",
boxShadow: (isNotesOpen || isSubTasksOpen || isResizing) ? "0 1px 3px rgba(0,0,0,0.05)" : "none",
display: "flex",
flexDirection: "column",
overflow: isNotesOpen || isSubTasksOpen ? "visible" : "hidden",
}}
draggable={!editingTaskId && !isResizing}
onDragStart={(e) => handleDragStart(e, task)}
onDragEnd={handleDragEnd}
onClick={(e) => {
e.stopPropagation();
if (editingTaskId !== task.id) toggleTask(task.id);
}}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: "0.25rem", width: "100%", justifyContent: "space-between" }}>
{editingTaskId === task.id ? (
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem("title") as HTMLInputElement;
updateTask(task.id, input.value);
}}
onClick={(e) => e.stopPropagation()}
style={{ width: "100%", paddingRight: "20px" }}
>
<input
name="title"
autoFocus
defaultValue={task.title}
onBlur={(e) => updateTask(task.id, e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") setEditingTaskId(null);
if (e.key === "Enter") e.currentTarget.blur();
}}
className="weekly-task-text"
style={{ width: "100%", background: "transparent", border: "none", borderBottom: "1px solid var(--weekly-border)", outline: "none" }}
/>
</form>
) : (
<span
style={{
display: "flex",
alignItems: "flex-start",
gap: "3px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
fontSize: "0.8rem",
}}
onDoubleClick={(e) => {
e.stopPropagation();
setEditingTaskId(task.id);
}}
>
{task.externalProvider && (
!task.externalId ||
!task.lastSyncedAt ||
new Date(task.updatedAt) > new Date(task.lastSyncedAt)
) && (
<span title="Needs to be synced" className="text-yellow-500 flex-shrink-0 mt-[2px]">
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38" />
</svg>
</span>
)}
{task.subTasks && task.subTasks.length > 0 && (
<span
className="task-subtask-icon cursor-pointer flex-shrink-0 mt-[2px]"
onClick={(e) => { e.stopPropagation(); setIsSubTasksOpen(!isSubTasksOpen); }}
>
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ transform: isSubTasksOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</span>
)}
{task.markdownContent && (
<span
className="task-note-icon cursor-pointer flex-shrink-0 mt-[2px]"
onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }}
>
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</span>
)}
{task.title}
</span>
)}
{(currentHeight >= 30 || isNotesOpen || isSubTasksOpen) && (
<div
className="task-actions"
style={{
display: "flex",
alignItems: "center",
gap: "2px",
marginLeft: "4px",
flexShrink: 0,
flexWrap: "nowrap",
background: darkMode ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.8)",
padding: "1px 3px",
borderRadius: "4px",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
}}
>
<button
className={`task-action-btn ${task.completed ? "active text-green-600 dark:text-green-500" : ""}`}
onClick={(e) => { e.stopPropagation(); toggleTask(task.id); }}
title={task.completed ? "Mark incomplete" : "Mark complete"}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button
className="task-action-btn"
onClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }}
title="Edit"
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button className={`task-action-btn ${isSubTaskInputOpen ? "active" : ""}`} onClick={(e) => { e.stopPropagation(); setIsSubTaskInputOpen(!isSubTaskInputOpen); if (!isSubTaskInputOpen) { setIsSubTasksOpen(true); setTimeout(() => subTaskInputRef.current?.focus(), 50); } }} title="Add sub-task">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button className={`task-action-btn ${isNotesOpen ? "active" : ""}`} onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }} title="Notes">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
{!task.completed && (
<button
className={`task-action-btn ${task.isRolling ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); toggleTaskRolling(task.id); }}
title={task.isRolling ? "Disable rolling" : "Enable rolling"}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
)}
<button className="task-action-btn delete text-red-500 hover:text-red-700 hover:bg-red-100/50 dark:hover:bg-red-900/30 rounded" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)}
</div>
{/* Inline Expanders Container */}
<div style={{ paddingLeft: "4px", paddingRight: "4px", paddingBottom: "10px", marginTop: "4px" }}>
{isNotesOpen && (
<div className="weekly-notes-inline mt-1" onClick={(e) => e.stopPropagation()}>
<div className="notes-toolbar">
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("**", "**")} title="Bold">B</button>
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("*", "*")} title="Italic">i</button>
<button className="notes-toolbar-btn" onClick={() => insertMarkdown("- ")} title="List"></button>
<span style={{ marginLeft: "auto", fontSize: "0.75rem", color: "#999" }}>Markdown</span>
</div>
<textarea
ref={notesRef}
className="weekly-notes-editor-inline"
value={notesValue}
onChange={(e) => setNotesValue(e.target.value)}
onBlur={handleNotesBlur}
placeholder="Add notes..."
style={{ minHeight: "60px", padding: "4px" }}
/>
</div>
)}
{isSubTasksOpen && task.subTasks && task.subTasks.length > 0 && (
<ul className="subtask-list mt-1" onClick={(e) => e.stopPropagation()}>
{task.subTasks.map((subTask: Task) => (
<li key={subTask.id} className={`subtask-item ${subTask.completed ? "completed" : ""}`}>
<button className="subtask-checkbox" onClick={() => toggleSubTask(subTask.id)}>
{subTask.completed ? (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
) : (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /></svg>
)}
</button>
{onSetEditingTaskId && editingTaskId === subTask.id ? (
<form onSubmit={(e) => { e.preventDefault(); const input = e.currentTarget.querySelector("input"); if (input) { updateSubTask(subTask.id, input.value); onSetEditingTaskId(null); } }} style={{ flex: 1 }}>
<input type="text" defaultValue={subTask.title} autoFocus className="subtask-edit-input" onBlur={(e) => { updateSubTask(subTask.id, e.target.value); onSetEditingTaskId(null); }} onKeyDown={(e) => { if (e.key === "Escape") onSetEditingTaskId(null); }} />
</form>
) : (
<span className={`subtask-title ${subTask.completed ? "completed" : ""}`} onClick={() => onSetEditingTaskId && onSetEditingTaskId(subTask.id)}>{subTask.title}</span>
)}
<button className="subtask-delete-btn" onClick={() => deleteSubTask(subTask.id)} title="Remove"><svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg></button>
</li>
))}
</ul>
)}
{isSubTaskInputOpen && (
<div className="subtask-add-row mt-1" onClick={(e) => e.stopPropagation()}>
<form onSubmit={(e) => { e.preventDefault(); if (newSubTaskTitle.trim()) { addSubTask(task.id, newSubTaskTitle.trim()); setNewSubTaskTitle(""); } }} style={{ display: "flex", alignItems: "center", gap: "0.25rem", flex: 1 }}>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="var(--weekly-text-muted, #999)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><circle cx="12" cy="12" r="10" /></svg>
<input ref={subTaskInputRef} type="text" value={newSubTaskTitle} onChange={(e) => setNewSubTaskTitle(e.target.value)} onBlur={() => { if (!newSubTaskTitle.trim()) setIsSubTaskInputOpen(false); }} onKeyDown={(e) => { if (e.key === "Escape") { setNewSubTaskTitle(""); setIsSubTaskInputOpen(false); } }} placeholder="Add sub-task..." className="subtask-add-input" autoFocus />
</form>
</div>
)}
</div>
{/* Resize Handle at Bottom */}
<div
className={`task-resize-handle ${isResizing ? "active" : ""}`}
onMouseDown={onResizeStart}
style={{
position: "absolute",
bottom: 0,
left: "10%",
right: "10%",
height: "6px",
cursor: "ns-resize",
display: "flex",
justifyContent: "center",
alignItems: "center",
paddingBottom: "2px",
}}
>
<div style={{ width: "20px", height: "3px", borderRadius: "2px", background: darkMode ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.15)" }} />
</div>
</div>
);
}

View File

@ -72,7 +72,7 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 600 }}>
Import from {provider === 'google' ? 'Google Tasks' : provider === 'outlook' ? 'Microsoft To-Do' : 'Apple Reminders'}
Sync with {provider === 'google' ? 'Google Tasks' : provider === 'outlook' ? 'Microsoft To-Do' : 'Apple Reminders'}
</h2>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-full">
<X size={20} />
@ -153,7 +153,7 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
gap: '8px'
}}
>
{isLoading ? 'Importing...' : 'Import Selected'}
{isLoading ? 'Syncing...' : 'Sync Selected'}
</button>
</div>
</div>

View File

@ -10,17 +10,39 @@ export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceMod
const [isRecurring, setIsRecurring] = useState(task.isRecurring || false);
const [interval, setInterval] = useState(task.recurrenceInterval || 1);
const [unit, setUnit] = useState(task.recurrenceUnit || 'weeks');
const [time, setTime] = useState(task.recurrenceTime || task.startTime || '09:00');
const [endDate, setEndDate] = useState(task.recurrenceEndDate ? new Date(task.recurrenceEndDate).toISOString().split('T')[0] : '');
const [isSaving, setIsSaving] = useState(false);
// Helper to format date as DD.MM.YYYY
const formatDate = (dateStr: string) => {
if (!dateStr) return '';
const [y, m, d] = dateStr.split('-');
return `${d}.${m}.${y}`;
};
// Helper to parse DD.MM.YYYY back to YYYY-MM-DD
const parseDateInput = (input: string) => {
const match = input.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (match) {
const d = match[1].padStart(2, '0');
const m = match[2].padStart(2, '0');
const y = match[3];
return `${y}-${m}-${d}`;
}
return input; // fallback to raw
};
const handleSave = async () => {
setIsSaving(true);
try {
const parsedEndDate = parseDateInput(endDate);
await onSave(task.id, {
isRecurring,
recurrenceInterval: isRecurring ? interval : null,
recurrenceUnit: isRecurring ? unit : null,
recurrenceEndDate: isRecurring && endDate ? new Date(endDate) : null
recurrenceTime: isRecurring ? time : null,
recurrenceEndDate: isRecurring && parsedEndDate ? new Date(parsedEndDate) : null
});
onClose();
} catch (error) {
@ -72,11 +94,46 @@ export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceMod
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '0.9rem', color: '#666' }}>End Date (Optional)</label>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '0.9rem', color: '#666' }}>Time</label>
<input
type="date"
value={endDate}
onChange={e => setEndDate(e.target.value)}
type="time"
value={time}
onChange={e => setTime(e.target.value)}
style={{ width: '100%', padding: '6px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '0.9rem', color: '#666' }}>End Date (Optional, DD.MM.YYYY)</label>
<input
type="text"
placeholder="DD.MM.YYYY"
value={endDate.includes('-') ? formatDate(endDate) : endDate}
onChange={(e) => {
const input = e.target.value;
// If user is deleting (new length < old length), just let them delete
if (input.length < endDate.length) {
setEndDate(input);
return;
}
let val = input.replace(/\D/g, ''); // Keep only digits
if (val.length > 8) val = val.slice(0, 8);
let formatted = val;
if (val.length > 2 && val.length <= 4) {
formatted = val.slice(0, 2) + '.' + val.slice(2);
} else if (val.length > 4) {
formatted = val.slice(0, 2) + '.' + val.slice(2, 4) + '.' + val.slice(4);
}
// If they typed a dot manually at the right position, don't interfere too much
if (input.endsWith('.') && (input.length === 3 || input.length === 6)) {
setEndDate(input);
} else {
setEndDate(formatted);
}
}}
style={{ width: '100%', padding: '6px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>

View File

@ -86,7 +86,7 @@ export default function TaskItem({ task, onToggleComplete, onDelete, onEdit, onT
const containerClass = variant === 'minimal'
? `flex items-center group py-1 border-b border-gray-100 hover:bg-gray-50 transition-colors ${isDeleting ? 'opacity-50' : ''}`
: `border border-gray-200 rounded-lg p-4 mb-2 bg-white shadow-sm transition-all duration-200 ${isDeleting ? 'opacity-50' : 'hover:shadow-md'}`;
: `border border-gray-200 rounded-lg p-4 mb-2 shadow-sm transition-all duration-200 ${isDeleting ? 'opacity-50' : 'hover:shadow-md'}`;
return (
<div className={containerClass}>

File diff suppressed because it is too large Load Diff

100
src/lib/apple-reminders.ts Normal file
View File

@ -0,0 +1,100 @@
import { DAVClient } from 'tsdav';
// iCloud CalDAV Server URL
const ICLOUD_CALDAV_URL = 'https://caldav.icloud.com';
export interface AppleReminderList {
id: string; // URL
title: string;
color?: string;
}
/**
* Create a configured DAV client for Apple iCloud
*/
const createClient = (email: string, appSpecificPassword: string) => {
return new DAVClient({
serverUrl: ICLOUD_CALDAV_URL,
credentials: {
username: email,
password: appSpecificPassword,
},
authMethod: 'Basic',
defaultAccountType: 'caldav',
});
};
/**
* Fetch all reminder lists (VTODO collections)
*/
export const fetchAppleReminderLists = async (email: string, appSpecificPassword: string): Promise<AppleReminderList[]> => {
try {
const client = createClient(email, appSpecificPassword);
await client.login();
const calendars = await client.fetchCalendars();
// Filter to VTODO collections (Reminders)
const reminderLists = calendars.filter(cal => {
const components: string[] = (cal as any).components || [];
return components.includes('VTODO');
});
return reminderLists.map(cal => ({
id: cal.url,
title: (cal.displayName as string) || 'Untitled List',
color: (cal as any).calendarColor,
}));
} catch (error) {
console.error('Error fetching Apple reminder lists:', error);
throw error;
}
};
/**
* Create a new reminder list (collection)
*/
export const createAppleReminderList = async (email: string, appSpecificPassword: string, title: string): Promise<AppleReminderList> => {
try {
const client = createClient(email, appSpecificPassword);
await client.login();
// tsdav's createDAVClient returns an object where we can use makeCalendar
// First we need a base URL. We can derive it from existing calendars.
const calendars = await client.fetchCalendars();
if (calendars.length === 0) {
throw new Error('No existing Apple calendars found to derive base URL');
}
// Deriving the base URL for new collections (usually parent of existing calendars)
const firstCalUrl = calendars[0].url;
const urlParts = firstCalUrl.split('/');
// Remove the last part (and trailing slash if exists)
if (firstCalUrl.endsWith('/')) urlParts.pop();
urlParts.pop();
const baseWebDavUrl = urlParts.join('/') + '/';
const newId = crypto.randomUUID();
const newCalendarUrl = `${baseWebDavUrl}${newId}/`;
// Create the calendar (reminder list)
await (client as any).makeCalendar({
url: newCalendarUrl,
props: {
'displayname': title,
'supported-calendar-component-set': {
'comp': { _attributes: { name: 'VTODO' } }
}
}
});
return {
id: newCalendarUrl,
title: title,
};
} catch (error) {
console.error('Error creating Apple reminder list:', error);
throw error;
}
};

View File

@ -14,6 +14,7 @@ export interface GoogleTask {
status: string;
due?: string;
updated: string;
parent?: string;
}
/**
@ -33,6 +34,27 @@ export const createGoogleClient = (accessToken: string, refreshToken?: string):
return oauth2Client;
};
/**
* Create a new task list
*/
export const createGoogleTaskList = async (client: OAuth2Client, title: string): Promise<GoogleTaskList> => {
const service = google.tasks({ version: 'v1', auth: client });
try {
const response = await service.tasklists.insert({
requestBody: { title }
});
const item = response.data;
return {
id: item.id!,
title: item.title!,
updated: item.updated!
};
} catch (error) {
console.error('Error creating Google task list:', error);
throw error;
}
};
/**
* Fetch all task lists for the user
*/
@ -69,7 +91,8 @@ export const fetchGoogleTasks = async (client: OAuth2Client, taskListId: string)
notes: item.notes || undefined,
status: item.status!,
due: item.due || undefined,
updated: item.updated!
updated: item.updated!,
parent: (item as any).parent || undefined,
}));
} catch (error) {
console.error(`Error fetching Google Tasks from list ${taskListId}:`, error);
@ -102,7 +125,8 @@ export const updateGoogleTask = async (client: OAuth2Client, taskListId: string,
notes: item.notes || undefined,
status: item.status!,
due: item.due || undefined,
updated: item.updated!
updated: item.updated!,
parent: (item as any).parent || undefined,
};
} catch (error) {
console.error(`Error updating Google Task ${taskId}:`, error);
@ -147,7 +171,8 @@ export const fetchGoogleTasksForSync = async (client: OAuth2Client, taskListId:
notes: item.notes || undefined,
status: item.status!,
due: item.due || undefined,
updated: item.updated!
updated: item.updated!,
parent: (item as any).parent || undefined,
}));
} catch (error) {
console.error(`Error fetching Google Tasks for sync from list ${taskListId}:`, error);
@ -174,7 +199,8 @@ export const updateGoogleTaskStatus = async (client: OAuth2Client, taskListId: s
notes: item.notes || undefined,
status: item.status!,
due: item.due || undefined,
updated: item.updated!
updated: item.updated!,
parent: (item as any).parent || undefined,
};
} catch (error) {
console.error(`Error updating Google Task ${taskId} in list ${taskListId}:`, error);

View File

@ -11,6 +11,13 @@ export interface MicrosoftTodoList {
wellknownListName: string;
}
export interface MicrosoftChecklistItem {
id: string;
displayName: string;
isChecked: boolean;
createdDateTime: string;
}
export interface MicrosoftTodoTask {
id: string;
title: string;
@ -30,8 +37,35 @@ export interface MicrosoftTodoTask {
};
createdDateTime: string;
lastModifiedDateTime: string;
checklistItems?: MicrosoftChecklistItem[];
}
/**
* Create a new Microsoft To-Do task list.
*/
export const createMsTodoList = async (accessToken: string, title: string): Promise<MicrosoftTodoList> => {
try {
const response = await fetch(`${GRAPH_ENDPOINT}/me/todo/lists`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ displayName: title })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'Failed to create Microsoft To-Do list');
}
return await response.json();
} catch (error) {
console.error('Error creating Microsoft To-Do list:', error);
throw error;
}
};
/**
* Fetch all Microsoft To-Do task lists.
*/
@ -209,3 +243,115 @@ export const deleteMsTodoTask = async (
export const isMsTodoTaskCompleted = (status: MicrosoftTodoTask['status']): boolean => {
return status === 'completed';
};
/**
* Fetch checklist items (sub-tasks) for a Microsoft To-Do task.
*/
export const fetchMsChecklistItems = async (
accessToken: string,
listId: string,
taskId: string
): Promise<MicrosoftChecklistItem[]> => {
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}/checklistItems`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
if (!response.ok) {
const err = await response.text();
console.error(`Error fetching checklist items for task ${taskId}:`, err);
return [];
}
const data = await response.json();
return (data.value || []) as MicrosoftChecklistItem[];
};
/**
* Create a checklist item (sub-task) for a Microsoft To-Do task.
*/
export const createMsChecklistItem = async (
accessToken: string,
listId: string,
taskId: string,
displayName: string
): Promise<MicrosoftChecklistItem> => {
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}/checklistItems`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ displayName })
}
);
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to create checklist item: ${err}`);
}
return response.json();
};
/**
* Update a checklist item (sub-task) for a Microsoft To-Do task.
*/
export const updateMsChecklistItem = async (
accessToken: string,
listId: string,
taskId: string,
checklistItemId: string,
updates: { displayName?: string; isChecked?: boolean }
): Promise<MicrosoftChecklistItem> => {
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}/checklistItems/${checklistItemId}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(updates)
}
);
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to update checklist item: ${err}`);
}
return response.json();
};
/**
* Delete a checklist item (sub-task) from a Microsoft To-Do task.
*/
export const deleteMsChecklistItem = async (
accessToken: string,
listId: string,
taskId: string,
checklistItemId: string
): Promise<void> => {
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}/checklistItems/${checklistItemId}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`
}
}
);
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to delete checklist item: ${err}`);
}
};

File diff suppressed because one or more lines are too long