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:
parent
999b354cc8
commit
f9d578ddf1
@ -7,6 +7,7 @@ Dockerfile
|
||||
docker-compose*.yml
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.planning
|
||||
prisma/dev.db
|
||||
*.md
|
||||
|
||||
43
.env.example
Normal file
43
.env.example
Normal 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
8
.gitignore
vendored
@ -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/
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
2
public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
99
src/app/api/someday-lists/external/route.ts
vendored
Normal file
99
src/app/api/someday-lists/external/route.ts
vendored
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
|
||||
|
||||
@ -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 })
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
417
src/components/GridTaskBlock.tsx
Normal file
417
src/components/GridTaskBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
100
src/lib/apple-reminders.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user