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:
@ -29,4 +25,4 @@ services:
volumes:
postgres_data:
cache:
cache:

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,14 +14,12 @@ 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:
- "15432:5432"
volumes:
postgres_data:
postgres_data:

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

@ -8,80 +8,89 @@ datasource db {
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String? // Optional for SSO users
name String?
image String?
emailVerified DateTime?
verifiedAt DateTime?
emailVerificationToken String?
id String @id @default(cuid())
email String @unique
passwordHash String?
name String?
image String?
emailVerified DateTime?
verifiedAt DateTime?
emailVerificationToken String?
emailVerificationExpires DateTime?
passwordResetToken String?
passwordResetExpires DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
timezone String @default("UTC")
autoRolling Boolean @default(false)
protectEventTimes Boolean @default(false)
language String @default("de")
dateFormat String @default("yyyy-MM-dd")
timeFormat String @default("24h")
startHour Int @default(8)
endHour Int @default(18)
showNextTask Boolean @default(false)
calendarEditMode Boolean @default(false)
focusTimerDuration Int @default(25)
focusBreakDuration Int @default(5)
showTimeGrid Boolean @default(true)
showSomeday Boolean @default(true)
showAllDayEvents Boolean @default(true)
showSchedule Boolean @default(true)
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"
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")
dateFontFamily String? @default("Inter")
dateFontSize String? @default("0.65rem")
dateFontWeight String? @default("400")
timeTaskFontFamily String? @default("Inter")
timeTaskFontSize String? @default("0.75rem")
timeTaskFontWeight String? @default("500")
bodyFont String @default("Inter")
taskFontFamily String? @default("Inter")
taskFontSize String? @default("0.9rem")
taskFontWeight String? @default("400")
eventFontFamily String? @default("Inter")
eventFontSize String? @default("0.85rem")
eventFontWeight String? @default("400")
fontWeight String @default("400") // Legacy/Generic
// Color Settings
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")
accounts Account[]
sessions Session[]
tasks Task[]
somedayLists SomedayList[]
calendarConnections CalendarConnection[]
cachedCalendarEvents CachedCalendarEvent[]
weeklyGoals WeeklyGoal[]
passwordResetToken String?
passwordResetExpires DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
timezone String @default("UTC")
autoRolling Boolean @default(false)
protectEventTimes Boolean @default(false)
language String @default("de")
dateFormat String @default("yyyy-MM-dd")
timeFormat String @default("24h")
startHour Int @default(8)
endHour Int @default(18)
showNextTask Boolean @default(false)
calendarEditMode Boolean @default(false)
focusTimerDuration Int @default(25)
focusBreakDuration Int @default(5)
showTimeGrid Boolean @default(true)
showSomeday Boolean @default(true)
showAllDayEvents Boolean @default(true)
showSchedule Boolean @default(true)
cellDuration Int @default(30)
viewStyle String @default("grid")
viewDays Int @default(7)
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")
headlineFont String @default("Inter")
headlineFontSize String? @default("1.25rem")
headlineFontWeight String? @default("900")
dateFontFamily String? @default("Inter")
dateFontSize String? @default("0.65rem")
dateFontWeight String? @default("400")
timeTaskFontFamily String? @default("Inter")
timeTaskFontSize String? @default("0.75rem")
timeTaskFontWeight String? @default("500")
bodyFont String @default("Inter")
taskFontFamily String? @default("Inter")
taskFontSize String? @default("0.9rem")
taskFontWeight String? @default("400")
eventFontFamily String? @default("Inter")
eventFontSize String? @default("0.85rem")
eventFontWeight String? @default("400")
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[]
cachedCalendarEvents CachedCalendarEvent[]
calendarConnections CalendarConnection[]
sessions Session[]
somedayLists SomedayList[]
tasks Task[]
weeklyGoals WeeklyGoal[]
}
model Account {
@ -90,15 +99,14 @@ 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)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
@ -120,97 +128,98 @@ model VerificationToken {
}
model Task {
id String @id @default(cuid())
userId String
title String
description String?
markdownContent String? @db.Text
completed Boolean @default(false)
isRolling Boolean @default(false)
order Int @default(0)
dayOfWeek Int? // 0-6 for Sunday-Saturday (legacy/someday lists)
scheduledDate DateTime? // Actual date for the task
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
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"
externalListId String?
lastSyncedAt DateTime?
id String @id @default(cuid())
userId String
title String
description String?
markdownContent String?
completed Boolean @default(false)
isRolling Boolean @default(false)
order Int @default(0)
dayOfWeek Int?
scheduledDate DateTime?
somedayListId String?
originalDate DateTime?
startTime String?
endTime String?
isRecurring Boolean @default(false)
recurrenceInterval Int?
recurrenceUnit String?
recurrenceTime String?
recurrenceEndDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
externalId String?
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 {
id String @id @default(cuid())
userId String
title String
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tasks Task[]
id String @id @default(cuid())
userId String
title String
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[]
@@index([userId])
}
model CalendarConnection {
id String @id @default(cuid())
userId String
provider String
accessToken String
id String @id @default(cuid())
userId String
provider String
accessToken String
refreshToken String?
expiresAt DateTime?
calendars Json? // Stores array of { id, title, isPrimary, selected }
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
expiresAt DateTime?
calendars Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cachedEvents CachedCalendarEvent[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model CachedCalendarEvent {
id String @id @default(cuid())
userId String
externalId String
connectionId String
provider String // "google" | "apple" | "outlook"
calendarId String
calendarTitle String
calendarColor String?
title String
description String? @db.Text
location String?
startDateTime DateTime?
startDate String? // YYYY-MM-DD for all-day events
endDateTime DateTime?
endDate String? // YYYY-MM-DD for all-day events
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)
id String @id @default(cuid())
userId String
externalId String
connectionId String
provider String
calendarId String
calendarTitle String
calendarColor String?
title String
description String?
location String?
startDateTime DateTime?
startDate String?
endDateTime DateTime?
endDate String?
weekStart DateTime
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
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])
@ -229,4 +238,4 @@ model WeeklyGoal {
@@unique([userId, weekStart])
@@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 { 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,18 +79,22 @@ export async function POST(req: NextRequest) {
}
for (const sourceList of targetLists) {
const googleTasks = await fetchGoogleTasks(client, sourceList.id);
importedTasks.push(...googleTasks.map(t => ({
title: t.title,
description: t.notes || '',
externalId: t.id,
externalListId: sourceList.id,
dueDate: t.due ? new Date(t.due) : null,
status: t.status,
sourceListTitle: sourceList.title,
})));
try {
const googleTasks = await fetchGoogleTasks(client, sourceList.id);
importedTasks.push(...googleTasks.map(t => ({
title: t.title,
description: t.notes || '',
externalId: t.id,
externalListId: sourceList.id,
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,21 +112,51 @@ export async function POST(req: NextRequest) {
}
for (const sourceList of targetLists) {
const msTasks = await fetchMsTodoTasks(accessToken, sourceList.id);
importedTasks.push(...msTasks.map(t => ({
title: t.title,
description: t.body?.content || '',
externalId: t.id,
externalListId: sourceList.id,
dueDate: t.dueDateTime ? new Date(t.dueDateTime.dateTime) : null,
status: isMsTodoTaskCompleted(t.status) ? 'completed' : 'notStarted',
sourceListTitle: sourceList.title,
})));
try {
const msTasks = await fetchMsTodoTasks(accessToken, sourceList.id);
for (const t of msTasks) {
importedTasks.push({
title: t.title,
description: t.body?.content || '',
externalId: t.id,
externalListId: sourceList.id,
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