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 docker-compose*.yml
.env .env
.env.* .env.*
!.env.example
.planning .planning
prisma/dev.db prisma/dev.db
*.md *.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
.env.local .env.local
.env.production .env.production
.env.development
.env.docker
.env.docker.dev
.env.docker.local
# Logs and temp files # Logs and temp files
*.log *.log
@ -29,6 +33,10 @@ node_modules/
/playwright/.cache/ /playwright/.cache/
/playwright/.auth/ /playwright/.auth/
# Development database
prisma/dev.db
prisma/dev.db-journal
# Next.js # Next.js
.next/ .next/

View File

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

View File

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

View File

@ -5,22 +5,8 @@ services:
image: my-weekly-todo:latest image: my-weekly-todo:latest
ports: ports:
- "13000:3000" - "13000:3000"
environment: env_file:
- DATABASE_URL=postgresql://root:WKE7xeZohxdZit7eObjG@db:5432/My-Weekly-ToDo-List?schema=public - .env.docker
- 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
depends_on: depends_on:
- db - db
volumes: volumes:
@ -29,10 +15,8 @@ services:
db: db:
image: postgres:15 image: postgres:15
environment: env_file:
POSTGRES_DB: My-Weekly-ToDo-List - .env.docker
POSTGRES_USER: root
POSTGRES_PASSWORD: WKE7xeZohxdZit7eObjG
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: ports:

View File

@ -8,80 +8,89 @@ datasource db {
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
passwordHash String? // Optional for SSO users passwordHash String?
name String? name String?
image String? image String?
emailVerified DateTime? emailVerified DateTime?
verifiedAt DateTime? verifiedAt DateTime?
emailVerificationToken String? emailVerificationToken String?
emailVerificationExpires DateTime? emailVerificationExpires DateTime?
passwordResetToken String? passwordResetToken String?
passwordResetExpires DateTime? passwordResetExpires DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
timezone String @default("UTC") timezone String @default("UTC")
autoRolling Boolean @default(false) autoRolling Boolean @default(false)
protectEventTimes Boolean @default(false) protectEventTimes Boolean @default(false)
language String @default("de") language String @default("de")
dateFormat String @default("yyyy-MM-dd") dateFormat String @default("yyyy-MM-dd")
timeFormat String @default("24h") timeFormat String @default("24h")
startHour Int @default(8) startHour Int @default(8)
endHour Int @default(18) endHour Int @default(18)
showNextTask Boolean @default(false) showNextTask Boolean @default(false)
calendarEditMode Boolean @default(false) calendarEditMode Boolean @default(false)
focusTimerDuration Int @default(25) focusTimerDuration Int @default(25)
focusBreakDuration Int @default(5) focusBreakDuration Int @default(5)
showTimeGrid Boolean @default(true) showTimeGrid Boolean @default(true)
showSomeday Boolean @default(true) showSomeday Boolean @default(true)
showAllDayEvents Boolean @default(true) showAllDayEvents Boolean @default(true)
showSchedule Boolean @default(true) showSchedule Boolean @default(true)
cellDuration Int @default(30) cellDuration Int @default(30)
viewStyle String @default("grid") viewStyle String @default("grid")
viewDays Int @default(7) viewDays Int @default(7)
fontSize String @default("M") // "S", "M", "L" fontSize String @default("M")
goalFallbackType String @default("quote") // "quote" | "next_todo" | "default" goalFallbackType String @default("quote")
goalDefaultSentence String @default("goal of the week") goalDefaultSentence String @default("goal of the week")
goalFontFamily String? @default("Inter") goalFontFamily String? @default("Inter")
goalFontSize String? @default("0.9rem") goalFontSize String? @default("0.9rem")
goalFontWeight String? @default("500") goalFontWeight String? @default("500")
goalScope String @default("week") // "week" | "day" headlineFont String @default("Inter")
headlineFont String @default("Inter") headlineFontSize String? @default("1.25rem")
headlineFontSize String? @default("1.25rem") headlineFontWeight String? @default("900")
headlineFontWeight String? @default("900") dateFontFamily String? @default("Inter")
dateFontFamily String? @default("Inter") dateFontSize String? @default("0.65rem")
dateFontSize String? @default("0.65rem") dateFontWeight String? @default("400")
dateFontWeight String? @default("400") timeTaskFontFamily String? @default("Inter")
timeTaskFontFamily String? @default("Inter") timeTaskFontSize String? @default("0.75rem")
timeTaskFontSize String? @default("0.75rem") timeTaskFontWeight String? @default("500")
timeTaskFontWeight String? @default("500") bodyFont String @default("Inter")
bodyFont String @default("Inter") taskFontFamily String? @default("Inter")
taskFontFamily String? @default("Inter") taskFontSize String? @default("0.9rem")
taskFontSize String? @default("0.9rem") taskFontWeight String? @default("400")
taskFontWeight String? @default("400") eventFontFamily String? @default("Inter")
eventFontFamily String? @default("Inter") eventFontSize String? @default("0.85rem")
eventFontSize String? @default("0.85rem") eventFontWeight String? @default("400")
eventFontWeight String? @default("400") fontWeight String @default("400")
fontWeight String @default("400") // Legacy/Generic weekdayColor String? @default("#888888")
dateColor String? @default("#888888")
// Color Settings taskColor String? @default("#333333")
weekdayColor String? @default("#888888") todayHighlightColor String? @default("#f0fafa")
dateColor String? @default("#888888") weekendColorSat String? @default("#666666")
taskColor String? @default("#333333") weekendColorSun String? @default("#dc2626")
todayHighlightColor String? @default("#f0fafa") pastDayColor String? @default("#a6a6a7")
goalScope String @default("week")
weekendColorSat String? @default("#666666") dateLayout String @default("right")
weekendColorSun String? @default("#dc2626") dateAlignment String @default("center")
pastDayColor String? @default("#a6a6a7") hourLabelFormat String @default("short")
showSubHourSlots Boolean @default(true)
accounts Account[] allDayPosition String @default("below")
sessions Session[] cwColor String? @default("#333333")
tasks Task[] cwFontFamily String? @default("Inter")
somedayLists SomedayList[] cwFontSize String? @default("1.125rem")
calendarConnections CalendarConnection[] cwFontWeight String? @default("700")
cachedCalendarEvents CachedCalendarEvent[] yearColor String? @default("#333333")
weeklyGoals WeeklyGoal[] yearFontFamily String? @default("Inter")
yearFontSize String? @default("1.125rem")
yearFontWeight String? @default("700")
accounts Account[]
cachedCalendarEvents CachedCalendarEvent[]
calendarConnections CalendarConnection[]
sessions Session[]
somedayLists SomedayList[]
tasks Task[]
weeklyGoals WeeklyGoal[]
} }
model Account { model Account {
@ -90,15 +99,14 @@ model Account {
type String type String
provider String provider String
providerAccountId String providerAccountId String
refresh_token String? @db.Text refresh_token String?
access_token String? @db.Text access_token String?
expires_at Int? expires_at Int?
token_type String? token_type String?
scope String? scope String?
id_token String? @db.Text id_token String?
session_state String? session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId]) @@unique([provider, providerAccountId])
} }
@ -120,97 +128,98 @@ model VerificationToken {
} }
model Task { model Task {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
title String title String
description String? description String?
markdownContent String? @db.Text markdownContent String?
completed Boolean @default(false) completed Boolean @default(false)
isRolling Boolean @default(false) isRolling Boolean @default(false)
order Int @default(0) order Int @default(0)
dayOfWeek Int? // 0-6 for Sunday-Saturday (legacy/someday lists) dayOfWeek Int?
scheduledDate DateTime? // Actual date for the task scheduledDate DateTime?
somedayListId String? somedayListId String?
originalDate DateTime? originalDate DateTime?
startTime String? startTime String?
endTime String? endTime String?
isRecurring Boolean @default(false) isRecurring Boolean @default(false)
recurrenceInterval Int? // Number of units between occurrences recurrenceInterval Int?
recurrenceUnit String? // "days" or "weeks" recurrenceUnit String?
recurrenceTime String? // e.g. "09:00" - time for the recurring task recurrenceTime String?
recurrenceEndDate DateTime? // Optional end date for recurrence recurrenceEndDate DateTime?
deletedAt DateTime? // Soft delete - null means active, set means trashed createdAt DateTime @default(now())
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
updatedAt DateTime @updatedAt externalId String?
externalProvider String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) externalListId String?
lastSyncedAt DateTime?
somedayList SomedayList? @relation(fields: [somedayListId], references: [id], onDelete: SetNull) deletedAt DateTime?
parentTaskId String?
// External Integration parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade)
externalId String? subTasks Task[] @relation("SubTasks")
externalProvider String? // "google" | "apple" | "outlook" somedayList SomedayList? @relation(fields: [somedayListId], references: [id])
externalListId String? user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lastSyncedAt DateTime?
@@index([userId, dayOfWeek]) @@index([userId, dayOfWeek])
@@index([userId, scheduledDate]) @@index([userId, scheduledDate])
@@index([userId, somedayListId]) @@index([userId, somedayListId])
@@index([userId, externalId]) @@index([userId, externalId])
@@index([userId, deletedAt]) @@index([userId, deletedAt])
@@index([parentTaskId])
} }
model SomedayList { model SomedayList {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
title String title String
order Int @default(0) order Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
externalId String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) externalProvider String?
tasks Task[] lastSyncedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tasks Task[]
@@index([userId]) @@index([userId])
} }
model CalendarConnection { model CalendarConnection {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
provider String provider String
accessToken String accessToken String
refreshToken String? refreshToken String?
expiresAt DateTime? expiresAt DateTime?
calendars Json? // Stores array of { id, title, isPrimary, selected } calendars Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
cachedEvents CachedCalendarEvent[] cachedEvents CachedCalendarEvent[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model CachedCalendarEvent { model CachedCalendarEvent {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
externalId String externalId String
connectionId String connectionId String
provider String // "google" | "apple" | "outlook" provider String
calendarId String calendarId String
calendarTitle String calendarTitle String
calendarColor String? calendarColor String?
title String title String
description String? @db.Text description String?
location String? location String?
startDateTime DateTime? startDateTime DateTime?
startDate String? // YYYY-MM-DD for all-day events startDate String?
endDateTime DateTime? endDateTime DateTime?
endDate String? // YYYY-MM-DD for all-day events endDate String?
weekStart DateTime weekStart DateTime
syncedAt DateTime @default(now()) syncedAt DateTime @default(now())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
@@unique([userId, externalId, provider]) @@unique([userId, externalId, provider])
@@index([userId, startDateTime]) @@index([userId, startDateTime])
@ -229,4 +238,4 @@ model WeeklyGoal {
@@unique([userId, weekStart]) @@unique([userId, weekStart])
@@index([userId]) @@index([userId])
} }

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 { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { createGoogleClient, fetchGoogleTasks, fetchGoogleTaskLists } from '@/lib/google-tasks'; 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'; import { getOutlookAccessToken } from '@/lib/outlook-token';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -22,6 +22,7 @@ interface ImportedTask {
dueDate: Date | null; dueDate: Date | null;
status: string; status: string;
sourceListTitle: string; sourceListTitle: string;
parentExternalId?: string;
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@ -57,6 +58,7 @@ export async function POST(req: NextRequest) {
} }
const importedTasks: ImportedTask[] = []; const importedTasks: ImportedTask[] = [];
let targetLists: SourceList[] = lists;
if (provider === 'google') { if (provider === 'google') {
const account = await prisma.account.findFirst({ 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); const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
let targetLists = lists;
if (targetLists.length === 0) { if (targetLists.length === 0) {
const googleLists = await fetchGoogleTaskLists(client); const googleLists = await fetchGoogleTaskLists(client);
if (googleLists.length > 0) { if (googleLists.length > 0) {
@ -78,18 +79,22 @@ export async function POST(req: NextRequest) {
} }
for (const sourceList of targetLists) { for (const sourceList of targetLists) {
const googleTasks = await fetchGoogleTasks(client, sourceList.id); try {
importedTasks.push(...googleTasks.map(t => ({ const googleTasks = await fetchGoogleTasks(client, sourceList.id);
title: t.title, importedTasks.push(...googleTasks.map(t => ({
description: t.notes || '', title: t.title,
externalId: t.id, description: t.notes || '',
externalListId: sourceList.id, externalId: t.id,
dueDate: t.due ? new Date(t.due) : null, externalListId: sourceList.id,
status: t.status, dueDate: t.due ? new Date(t.due) : null,
sourceListTitle: sourceList.title, 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') { if (provider === 'outlook') {
@ -98,7 +103,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Outlook account not connected' }, { status: 400 }); return NextResponse.json({ error: 'Outlook account not connected' }, { status: 400 });
} }
let targetLists = lists;
if (targetLists.length === 0) { if (targetLists.length === 0) {
const msTodoLists = await fetchMsTodoLists(accessToken); const msTodoLists = await fetchMsTodoLists(accessToken);
if (msTodoLists.length > 0) { if (msTodoLists.length > 0) {
@ -108,21 +112,51 @@ export async function POST(req: NextRequest) {
} }
for (const sourceList of targetLists) { for (const sourceList of targetLists) {
const msTasks = await fetchMsTodoTasks(accessToken, sourceList.id); try {
importedTasks.push(...msTasks.map(t => ({ const msTasks = await fetchMsTodoTasks(accessToken, sourceList.id);
title: t.title, for (const t of msTasks) {
description: t.body?.content || '', importedTasks.push({
externalId: t.id, title: t.title,
externalListId: sourceList.id, description: t.body?.content || '',
dueDate: t.dueDateTime ? new Date(t.dueDateTime.dateTime) : null, externalId: t.id,
status: isMsTodoTaskCompleted(t.status) ? 'completed' : 'notStarted', externalListId: sourceList.id,
sourceListTitle: sourceList.title, 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 // Group tasks by source list title
const tasksByList = new Map<string, ImportedTask[]>(); 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) { for (const task of importedTasks) {
const listTitle = task.sourceListTitle; const listTitle = task.sourceListTitle;
if (!tasksByList.has(listTitle)) { if (!tasksByList.has(listTitle)) {
@ -137,6 +171,11 @@ export async function POST(req: NextRequest) {
let updatedCount = 0; let updatedCount = 0;
let listsCreated = 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) { for (const [listTitle, tasks] of tasksByList) {
let somedayList = await prisma.somedayList.findFirst({ let somedayList = await prisma.somedayList.findFirst({
where: { userId: user.id, title: listTitle } where: { userId: user.id, title: listTitle }
@ -150,7 +189,15 @@ export async function POST(req: NextRequest) {
console.log(`[IMPORT] Created SomedayList "${listTitle}" (${somedayList.id})`); 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({ const existingTask = await prisma.task.findFirst({
where: { userId: user.id, externalId: task.externalId, externalProvider: provider } where: { userId: user.id, externalId: task.externalId, externalProvider: provider }
}); });
@ -163,9 +210,13 @@ export async function POST(req: NextRequest) {
lastSyncedAt: new Date() lastSyncedAt: new Date()
} }
}); });
externalToLocalId.set(task.externalId, existingTask.id);
if (task.parentExternalId) {
pendingParentLinks.push({ localId: existingTask.id, parentExternalId: task.parentExternalId });
}
updatedCount++; updatedCount++;
} else { } else {
await prisma.task.create({ const newTask = await prisma.task.create({
data: { data: {
userId: user.id, userId: user.id,
title: task.title, title: task.title,
@ -178,11 +229,26 @@ export async function POST(req: NextRequest) {
lastSyncedAt: new Date() lastSyncedAt: new Date()
} }
}); });
externalToLocalId.set(task.externalId, newTask.id);
if (task.parentExternalId) {
pendingParentLinks.push({ localId: newTask.id, parentExternalId: task.parentExternalId });
}
count++; 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}`); console.log(`[IMPORT] Done! Created: ${count}, Updated: ${updatedCount}, Lists created: ${listsCreated}`);
return NextResponse.json({ success: true, count, updatedCount, 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 start = searchParams.get('start');
const end = searchParams.get('end'); 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 includeDeleted = searchParams.get('includeDeleted') === 'true';
const tasks = await prisma.task.findMany({ const tasks = await prisma.task.findMany({
where: { where: {
userId, userId,
...(includeDeleted ? {} : { deletedAt: null }), ...(includeDeleted ? {} : { deletedAt: null }),
}, },
include: {
subTasks: {
where: includeDeleted ? {} : { deletedAt: null },
orderBy: { order: 'asc' },
},
},
orderBy: [ orderBy: [
{ dayOfWeek: 'asc' }, { dayOfWeek: 'asc' },
{ order: 'asc' }, { order: 'asc' },
@ -189,7 +195,7 @@ export async function POST(request: NextRequest) {
const userId = (session.user as any).id; const userId = (session.user as any).id;
const body = await request.json(); 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; let { isRolling } = body;
const { isRecurring } = body; const { isRecurring } = body;
@ -225,7 +231,8 @@ export async function POST(request: NextRequest) {
recurrenceInterval: recurrenceInterval ? parseInt(recurrenceInterval) : null, recurrenceInterval: recurrenceInterval ? parseInt(recurrenceInterval) : null,
recurrenceUnit, recurrenceUnit,
recurrenceTime, 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 body = await request.json();
const { id } = body; 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) { if (!id) {
return NextResponse.json( return NextResponse.json(
@ -341,7 +348,8 @@ export async function PATCH(request: NextRequest) {
...(recurrenceUnit !== undefined && { recurrenceUnit }), ...(recurrenceUnit !== undefined && { recurrenceUnit }),
...(recurrenceTime !== undefined && { recurrenceTime }), ...(recurrenceTime !== undefined && { recurrenceTime }),
...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }), ...(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 { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client'; 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 { fetchMsTodoTasksForSync, updateMsTodoTask, deleteMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token'; 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 googleLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'google');
const outlookLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'outlook'); const outlookLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'outlook');
let updated = 0; let updated = 0;
let deleted = 0; let deleted = 0;
let created = 0;
// --- Google Tasks pull-sync --- // --- 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({ const account = await prisma.account.findFirst({
where: { userId: user.id, provider: 'google' } where: { userId: user.id, provider: 'google' }
}); });
@ -48,6 +61,21 @@ export async function GET(req: NextRequest) {
if (account?.access_token) { if (account?.access_token) {
const client = createGoogleClient(account.access_token, account.refresh_token || undefined); 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>(); const googleByList = new Map<string, typeof googleLocalTasks>();
for (const task of googleLocalTasks) { for (const task of googleLocalTasks) {
if (!task.externalListId) continue; if (!task.externalListId) continue;
@ -57,12 +85,23 @@ export async function GET(req: NextRequest) {
googleByList.get(task.externalListId)!.push(task); googleByList.get(task.externalListId)!.push(task);
} }
for (const [listId, tasks] of googleByList) { for (const listId of googleListIds) {
const localTasks = googleByList.get(listId) || [];
try { try {
const remoteTasks = await fetchGoogleTasksForSync(client, listId); const remoteTasks = await fetchGoogleTasksForSync(client, listId);
const remoteMap = new Map(remoteTasks.map(t => [t.id, t])); 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!); const remote = remoteMap.get(localTask.externalId!);
if (!remote) { if (!remote) {
@ -93,6 +132,16 @@ export async function GET(req: NextRequest) {
updateData.description = remote.notes || null; 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) { if (Object.keys(updateData).length > 1) {
await prisma.task.update({ await prisma.task.update({
where: { id: localTask.id }, 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) { } catch (listError) {
console.error(`Error syncing Google list ${listId}:`, listError); console.error(`Error syncing Google list ${listId}:`, listError);
} }
@ -114,10 +201,28 @@ export async function GET(req: NextRequest) {
} }
// --- Microsoft To-Do pull-sync --- // --- 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); const outlookToken = await getOutlookAccessToken(user.id);
if (outlookToken) { 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>(); const outlookByList = new Map<string, typeof outlookLocalTasks>();
for (const task of outlookLocalTasks) { for (const task of outlookLocalTasks) {
if (!task.externalListId) continue; if (!task.externalListId) continue;
@ -127,12 +232,15 @@ export async function GET(req: NextRequest) {
outlookByList.get(task.externalListId)!.push(task); outlookByList.get(task.externalListId)!.push(task);
} }
for (const [listId, tasks] of outlookByList) { for (const listId of outlookListIds) {
const localTasks = outlookByList.get(listId) || [];
try { try {
const remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId); const remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
const remoteMap = new Map(remoteTasks.map(t => [t.id, t])); 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!); const remote = remoteMap.get(localTask.externalId!);
if (!remote) { 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) { } catch (listError) {
console.error(`Error syncing Outlook list ${listId}:`, 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) { } catch (error: unknown) {
console.error('Pull sync error:', error); console.error('Pull sync error:', error);

View File

@ -40,6 +40,7 @@ export async function GET(request: NextRequest) {
goalFontSize: true, goalFontSize: true,
goalFontWeight: true, goalFontWeight: true,
goalScope: true, goalScope: true,
dateLayout: true,
headlineFont: true, headlineFont: true,
headlineFontSize: true, headlineFontSize: true,
headlineFontWeight: true, headlineFontWeight: true,
@ -64,6 +65,17 @@ export async function GET(request: NextRequest) {
weekendColorSat: true, weekendColorSat: true,
weekendColorSun: true, weekendColorSun: true,
pastDayColor: 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 createdAt: true
} }
}); });
@ -99,7 +111,11 @@ export async function PATCH(request: NextRequest) {
fontWeight, weekendColorSat, weekendColorSun, fontWeight, weekendColorSat, weekendColorSun,
weekdayColor, dateColor, taskColor, todayHighlightColor, weekdayColor, dateColor, taskColor, todayHighlightColor,
pastDayColor, goalFallbackType, goalDefaultSentence, pastDayColor, goalFallbackType, goalDefaultSentence,
goalFontFamily, goalFontSize, goalFontWeight, goalScope goalFontFamily, goalFontSize, goalFontWeight, goalScope,
dateLayout,
hourLabelFormat, showSubHourSlots, allDayPosition,
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
yearFontFamily, yearFontSize, yearFontWeight, yearColor
} = body; } = body;
const updateData: any = { const updateData: any = {
@ -154,6 +170,18 @@ export async function PATCH(request: NextRequest) {
...(goalFontSize !== undefined && { goalFontSize }), ...(goalFontSize !== undefined && { goalFontSize }),
...(goalFontWeight !== undefined && { goalFontWeight }), ...(goalFontWeight !== undefined && { goalFontWeight }),
...(goalScope !== undefined && { goalScope }), ...(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() !== "") { if (password && password.trim() !== "") {
updateData.passwordHash = await bcrypt.hash(password, 10); updateData.passwordHash = await bcrypt.hash(password, 10);
@ -210,12 +238,24 @@ export async function PATCH(request: NextRequest) {
weekendColorSat: true, weekendColorSat: true,
weekendColorSun: true, weekendColorSun: true,
pastDayColor: true, pastDayColor: true,
hourLabelFormat: true,
showSubHourSlots: true,
allDayPosition: true,
goalFallbackType: true, goalFallbackType: true,
goalDefaultSentence: true, goalDefaultSentence: true,
goalFontFamily: true, goalFontFamily: true,
goalFontSize: true, goalFontSize: true,
goalFontWeight: true, goalFontWeight: true,
goalScope: 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; 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 { .time-slot-label {
display: flex; 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; justify-content: flex-end;
padding: 0 0.5rem; /* Removed top padding */ padding: 0 0.5rem;
font-size: 0.65rem; font-size: 0.65rem;
color: var(--weekly-text-light); color: var(--weekly-text-light);
box-sizing: border-box; box-sizing: border-box;
@ -2023,7 +2007,7 @@ h3 {
.now-line { .now-line {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 40px;
height: 2px; height: 2px;
background: #d50000; background: #d50000;
z-index: 10; z-index: 10;
@ -2031,7 +2015,19 @@ h3 {
} }
.now-line::before { .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 { .now-line::after {
@ -2940,3 +2936,16 @@ h3 {
max-width: 100vw; 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' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 600 }}> <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> </h2>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-full"> <button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-full">
<X size={20} /> <X size={20} />
@ -153,7 +153,7 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
gap: '8px' gap: '8px'
}} }}
> >
{isLoading ? 'Importing...' : 'Import Selected'} {isLoading ? 'Syncing...' : 'Sync Selected'}
</button> </button>
</div> </div>
</div> </div>

View File

@ -10,17 +10,39 @@ export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceMod
const [isRecurring, setIsRecurring] = useState(task.isRecurring || false); const [isRecurring, setIsRecurring] = useState(task.isRecurring || false);
const [interval, setInterval] = useState(task.recurrenceInterval || 1); const [interval, setInterval] = useState(task.recurrenceInterval || 1);
const [unit, setUnit] = useState(task.recurrenceUnit || 'weeks'); 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 [endDate, setEndDate] = useState(task.recurrenceEndDate ? new Date(task.recurrenceEndDate).toISOString().split('T')[0] : '');
const [isSaving, setIsSaving] = useState(false); 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 () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
try { try {
const parsedEndDate = parseDateInput(endDate);
await onSave(task.id, { await onSave(task.id, {
isRecurring, isRecurring,
recurrenceInterval: isRecurring ? interval : null, recurrenceInterval: isRecurring ? interval : null,
recurrenceUnit: isRecurring ? unit : null, recurrenceUnit: isRecurring ? unit : null,
recurrenceEndDate: isRecurring && endDate ? new Date(endDate) : null recurrenceTime: isRecurring ? time : null,
recurrenceEndDate: isRecurring && parsedEndDate ? new Date(parsedEndDate) : null
}); });
onClose(); onClose();
} catch (error) { } catch (error) {
@ -72,11 +94,46 @@ export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceMod
</div> </div>
<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 <input
type="date" type="time"
value={endDate} value={time}
onChange={e => setEndDate(e.target.value)} 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' }} style={{ width: '100%', padding: '6px', borderRadius: '4px', border: '1px solid #ddd' }}
/> />
</div> </div>

View File

@ -86,7 +86,7 @@ export default function TaskItem({ task, onToggleComplete, onDelete, onEdit, onT
const containerClass = variant === 'minimal' const containerClass = variant === 'minimal'
? `flex items-center group py-1 border-b border-gray-100 hover:bg-gray-50 transition-colors ${isDeleting ? 'opacity-50' : ''}` ? `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 ( return (
<div className={containerClass}> <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; status: string;
due?: string; due?: string;
updated: string; updated: string;
parent?: string;
} }
/** /**
@ -33,6 +34,27 @@ export const createGoogleClient = (accessToken: string, refreshToken?: string):
return oauth2Client; 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 * Fetch all task lists for the user
*/ */
@ -69,7 +91,8 @@ export const fetchGoogleTasks = async (client: OAuth2Client, taskListId: string)
notes: item.notes || undefined, notes: item.notes || undefined,
status: item.status!, status: item.status!,
due: item.due || undefined, due: item.due || undefined,
updated: item.updated! updated: item.updated!,
parent: (item as any).parent || undefined,
})); }));
} catch (error) { } catch (error) {
console.error(`Error fetching Google Tasks from list ${taskListId}:`, 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, notes: item.notes || undefined,
status: item.status!, status: item.status!,
due: item.due || undefined, due: item.due || undefined,
updated: item.updated! updated: item.updated!,
parent: (item as any).parent || undefined,
}; };
} catch (error) { } catch (error) {
console.error(`Error updating Google Task ${taskId}:`, error); console.error(`Error updating Google Task ${taskId}:`, error);
@ -147,7 +171,8 @@ export const fetchGoogleTasksForSync = async (client: OAuth2Client, taskListId:
notes: item.notes || undefined, notes: item.notes || undefined,
status: item.status!, status: item.status!,
due: item.due || undefined, due: item.due || undefined,
updated: item.updated! updated: item.updated!,
parent: (item as any).parent || undefined,
})); }));
} catch (error) { } catch (error) {
console.error(`Error fetching Google Tasks for sync from list ${taskListId}:`, 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, notes: item.notes || undefined,
status: item.status!, status: item.status!,
due: item.due || undefined, due: item.due || undefined,
updated: item.updated! updated: item.updated!,
parent: (item as any).parent || undefined,
}; };
} catch (error) { } catch (error) {
console.error(`Error updating Google Task ${taskId} in list ${taskListId}:`, error); console.error(`Error updating Google Task ${taskId} in list ${taskListId}:`, error);

View File

@ -11,6 +11,13 @@ export interface MicrosoftTodoList {
wellknownListName: string; wellknownListName: string;
} }
export interface MicrosoftChecklistItem {
id: string;
displayName: string;
isChecked: boolean;
createdDateTime: string;
}
export interface MicrosoftTodoTask { export interface MicrosoftTodoTask {
id: string; id: string;
title: string; title: string;
@ -30,8 +37,35 @@ export interface MicrosoftTodoTask {
}; };
createdDateTime: string; createdDateTime: string;
lastModifiedDateTime: 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. * Fetch all Microsoft To-Do task lists.
*/ */
@ -209,3 +243,115 @@ export const deleteMsTodoTask = async (
export const isMsTodoTaskCompleted = (status: MicrosoftTodoTask['status']): boolean => { export const isMsTodoTaskCompleted = (status: MicrosoftTodoTask['status']): boolean => {
return status === 'completed'; 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