From eec3d031c84685226c77a5dd30d7164c3992bfeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=B5=20Ho=C3=A0ng=20Ph=C3=BAc?= Date: Mon, 9 Jun 2025 11:40:40 +0700 Subject: [PATCH 1/5] feat: add dayjs dependency and implement scheduling algorithm with tests --- bun.lock | 1 + packages/ai/package.json | 1 + packages/ai/src/scheduling/algorithm.test.ts | 184 +++++++++++++++++++ packages/ai/src/scheduling/algorithm.ts | 57 ++++++ 4 files changed, 243 insertions(+) create mode 100644 packages/ai/src/scheduling/algorithm.test.ts create mode 100644 packages/ai/src/scheduling/algorithm.ts diff --git a/bun.lock b/bun.lock index 27cd62b584..9951469a2f 100644 --- a/bun.lock +++ b/bun.lock @@ -552,6 +552,7 @@ "@tuturuuu/supabase": "workspace:*", "@tuturuuu/utils": "workspace:*", "ai": "^4.3.16", + "dayjs": "^1.11.13", "eslint": "^9.28.0", "next": "15.3.3", "react": "^19.1.0", diff --git a/packages/ai/package.json b/packages/ai/package.json index df96467140..90a8b392dd 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -43,6 +43,7 @@ "@tuturuuu/supabase": "workspace:*", "@tuturuuu/utils": "workspace:*", "ai": "^4.3.16", + "dayjs": "^1.11.13", "eslint": "^9.28.0", "next": "15.3.3", "react": "^19.1.0", diff --git a/packages/ai/src/scheduling/algorithm.test.ts b/packages/ai/src/scheduling/algorithm.test.ts new file mode 100644 index 0000000000..4015138db9 --- /dev/null +++ b/packages/ai/src/scheduling/algorithm.test.ts @@ -0,0 +1,184 @@ +import { defaultActiveHours, defaultTasks } from './algorithm'; +import dayjs from 'dayjs'; +import { describe, expect, it } from 'vitest'; + +describe('Scheduling Algorithm', () => { + describe('DateRange interface', () => { + it('should create valid date ranges', () => { + const start = dayjs('2024-01-01T09:00:00'); + const end = dayjs('2024-01-01T17:00:00'); + + const range = { start, end }; + + expect(range.start.isValid()).toBe(true); + expect(range.end.isValid()).toBe(true); + expect(range.end.isAfter(range.start)).toBe(true); + }); + }); + + describe('Event interface', () => { + it('should create valid events', () => { + const event = { + id: 'event-1', + name: 'Team Meeting', + range: { + start: dayjs('2024-01-01T10:00:00'), + end: dayjs('2024-01-01T11:00:00'), + }, + }; + + expect(event.id).toBe('event-1'); + expect(event.name).toBe('Team Meeting'); + expect(event.range.start.isValid()).toBe(true); + expect(event.range.end.isValid()).toBe(true); + }); + }); + + describe('Task interface', () => { + it('should create valid tasks', () => { + const task = { + id: 'task-1', + name: 'Complete project', + duration: 120, // 2 hours in minutes + events: [], + }; + + expect(task.id).toBe('task-1'); + expect(task.name).toBe('Complete project'); + expect(task.duration).toBe(120); + expect(Array.isArray(task.events)).toBe(true); + }); + + it('should create tasks with events', () => { + const task = { + id: 'task-2', + name: 'Review code', + duration: 60, + events: [ + { + id: 'event-1', + name: 'Code review session', + range: { + start: dayjs('2024-01-01T14:00:00'), + end: dayjs('2024-01-01T15:00:00'), + }, + }, + ], + }; + + expect(task.events).toHaveLength(1); + expect(task.events[0]?.name).toBe('Code review session'); + }); + }); + + describe('ActiveHours interface', () => { + it('should have valid structure for active hours', () => { + const activeHours = { + personal: [ + { + start: dayjs().hour(7).minute(0).second(0).millisecond(0), + end: dayjs().hour(23).minute(0).second(0).millisecond(0), + }, + ], + work: [ + { + start: dayjs().hour(9).minute(0).second(0).millisecond(0), + end: dayjs().hour(17).minute(0).second(0).millisecond(0), + }, + ], + meeting: [ + { + start: dayjs().hour(9).minute(0).second(0).millisecond(0), + end: dayjs().hour(17).minute(0).second(0).millisecond(0), + }, + ], + }; + + expect(Array.isArray(activeHours.personal)).toBe(true); + expect(Array.isArray(activeHours.work)).toBe(true); + expect(Array.isArray(activeHours.meeting)).toBe(true); + + expect(activeHours.personal[0]?.start.isValid()).toBe(true); + expect(activeHours.work[0]?.end.isAfter(activeHours.work[0]?.start)).toBe( + true + ); + }); + }); + + describe('DefaultActiveHours', () => { + it('should have correct default active hours configuration', () => { + expect(defaultActiveHours).toBeDefined(); + expect(defaultActiveHours.personal).toHaveLength(1); + expect(defaultActiveHours.work).toHaveLength(1); + expect(defaultActiveHours.meeting).toHaveLength(1); + + // Test personal hours (7:00 - 23:00) + expect(defaultActiveHours.personal[0]?.start.format('HH:mm')).toBe( + '07:00' + ); + expect(defaultActiveHours.personal[0]?.end.format('HH:mm')).toBe('23:00'); + + // Test work hours (9:00 - 17:00) + expect(defaultActiveHours.work[0]?.start.format('HH:mm')).toBe('09:00'); + expect(defaultActiveHours.work[0]?.end.format('HH:mm')).toBe('17:00'); + + // Test meeting hours (9:00 - 17:00) + expect(defaultActiveHours.meeting[0]?.start.format('HH:mm')).toBe( + '09:00' + ); + expect(defaultActiveHours.meeting[0]?.end.format('HH:mm')).toBe('17:00'); + }); + }); + + describe('DefaultTasks', () => { + it('should have correct default tasks configuration', () => { + expect(defaultTasks).toBeDefined(); + expect(Array.isArray(defaultTasks)).toBe(true); + expect(defaultTasks).toHaveLength(1); + }); + + it('should have valid task structure', () => { + const task = defaultTasks[0]; + + expect(task).toBeDefined(); + expect(task?.id).toBe('task-1'); + expect(task?.name).toBe('Task 1'); + expect(task?.duration).toBe(1); + expect(Array.isArray(task?.events)).toBe(true); + expect(task?.events).toHaveLength(0); + }); + + it('should have valid task properties types', () => { + const task = defaultTasks[0]; + + expect(typeof task?.id).toBe('string'); + expect(typeof task?.name).toBe('string'); + expect(typeof task?.duration).toBe('number'); + expect(Array.isArray(task?.events)).toBe(true); + }); + + it('should have positive duration', () => { + const task = defaultTasks[0]; + + expect(task?.duration).toBeGreaterThan(0); + }); + + it('should have non-empty id and name', () => { + const task = defaultTasks[0]; + + expect(task?.id).toBeTruthy(); + expect(task?.name).toBeTruthy(); + expect(task?.id.length).toBeGreaterThan(0); + expect(task?.name.length).toBeGreaterThan(0); + }); + }); + + // TODO: Add tests for the schedule function once it's implemented + describe('schedule function', () => { + it.todo('should schedule tasks without conflicts'); + it.todo('should respect active hours constraints'); + it.todo('should handle overlapping events'); + it.todo('should return empty schedule for no tasks'); + it.todo('should prioritize tasks based on duration'); + }); +}); diff --git a/packages/ai/src/scheduling/algorithm.ts b/packages/ai/src/scheduling/algorithm.ts new file mode 100644 index 0000000000..6befbaba18 --- /dev/null +++ b/packages/ai/src/scheduling/algorithm.ts @@ -0,0 +1,57 @@ +import dayjs from 'dayjs'; + +interface DateRange { + start: dayjs.Dayjs; + end: dayjs.Dayjs; +} + +interface Event { + id: string; + name: string; + range: DateRange; +} + +interface Task { + id: string; + name: string; + duration: number; + events: Event[]; +} + +interface ActiveHours { + personal: DateRange[]; + work: DateRange[]; + meeting: DateRange[]; +} + +export const defaultActiveHours: ActiveHours = { + personal: [ + { + start: dayjs().hour(7).minute(0).second(0).millisecond(0), + end: dayjs().hour(23).minute(0).second(0).millisecond(0), + }, + ], + work: [ + { + start: dayjs().hour(9).minute(0).second(0).millisecond(0), + end: dayjs().hour(17).minute(0).second(0).millisecond(0), + }, + ], + meeting: [ + { + start: dayjs().hour(9).minute(0).second(0).millisecond(0), + end: dayjs().hour(17).minute(0).second(0).millisecond(0), + }, + ], +}; + +export const defaultTasks: Task[] = [ + { + id: 'task-1', + name: 'Task 1', + duration: 1, + events: [], + }, +]; + +// export const schedule = (events: Event[], tasks: Task[]) => {}; From 255996a913c75c456b00e94c32e5899b3327bf31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=B5=20Ho=C3=A0ng=20Ph=C3=BAc?= Date: Tue, 17 Jun 2025 10:03:41 +0700 Subject: [PATCH 2/5] feat(scheduler): implement scheduling page and enhance task management --- .../calendar/src/app/[locale]/(root)/page.tsx | 8 + .../app/[locale]/(root)/scheduler/page.tsx | 344 ++++++++++++++++++ apps/calendar/src/app/[locale]/layout.tsx | 2 +- packages/ai/src/scheduling/algorithm.ts | 119 +++++- packages/types/src/supabase.ts | 154 ++++---- 5 files changed, 544 insertions(+), 83 deletions(-) create mode 100644 apps/calendar/src/app/[locale]/(root)/scheduler/page.tsx diff --git a/apps/calendar/src/app/[locale]/(root)/page.tsx b/apps/calendar/src/app/[locale]/(root)/page.tsx index f782c629d5..62ddf9c001 100644 --- a/apps/calendar/src/app/[locale]/(root)/page.tsx +++ b/apps/calendar/src/app/[locale]/(root)/page.tsx @@ -1,8 +1,11 @@ 'use client'; +import { DEV_MODE } from '@/constants/common'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Button } from '@tuturuuu/ui/button'; import { SmartCalendar } from '@tuturuuu/ui/legacy/calendar/smart-calendar'; import { useLocale, useTranslations } from 'next-intl'; +import Link from 'next/link'; export default function Home() { const t = useTranslations('calendar'); @@ -11,6 +14,11 @@ export default function Home() { return (
+ {DEV_MODE && ( + + + + )} (defaultTasks); + const [events, setEvents] = useState([]); + const [logs, setLogs] = useState([]); + const [activeHours, setActiveHours] = + useState(defaultActiveHours); + + // States for the new task form + const [newTaskName, setNewTaskName] = useState(''); + const [newTaskDuration, setNewTaskDuration] = useState(1); + const [newTaskDeadline, setNewTaskDeadline] = useState(''); + + const addTask = () => { + if (!newTaskName.trim() || newTaskDuration <= 0) return; + const newTask: Task = { + id: `task-${Date.now()}`, + name: newTaskName, + duration: newTaskDuration, + deadline: newTaskDeadline ? dayjs(newTaskDeadline) : undefined, + events: [], + }; + setTasks([...tasks, newTask]); + setNewTaskName(''); + setNewTaskDuration(1); + setNewTaskDeadline(''); + }; + + const updateTask = (id: string, updatedTask: Partial) => { + setTasks( + tasks.map((task) => (task.id === id ? { ...task, ...updatedTask } : task)) + ); + }; + + const deleteTask = (id: string) => { + setTasks(tasks.filter((task) => task.id !== id)); + }; + + const handleSchedule = () => { + const { events: scheduledEvents, logs: scheduleLogs } = scheduleTasks( + tasks, + activeHours + ); + setEvents(scheduledEvents); + setLogs(scheduleLogs); + }; + + const handleActiveHoursChange = ( + category: keyof ActiveHours, + index: number, + field: 'start' | 'end', + value: string + ) => { + const newActiveHours = { ...activeHours }; + const [hour, minute] = value.split(':').map(Number); + newActiveHours[category][index][field] = dayjs().hour(hour).minute(minute); + setActiveHours(newActiveHours); + }; + + const groupedEvents = useMemo(() => { + return events.reduce( + (acc, event) => { + const date = event.range.start.format('YYYY-MM-DD'); + if (!acc[date]) { + acc[date] = []; + } + acc[date].push(event); + return acc; + }, + {} as Record + ); + }, [events]); + + return ( +
+
+ {/* Column 1: Configuration */} +
+ {/* Active Hours Configuration */} + + + Active Hours + + Set your available time ranges for scheduling. + + + + {Object.entries(activeHours).map( + ([category, ranges]: [string, DateRange[]]) => ( +
+

+ {category} +

+ {ranges.map((range, index) => ( +
+ + handleActiveHoursChange( + category as keyof ActiveHours, + index, + 'start', + e.target.value + ) + } + /> + - + + handleActiveHoursChange( + category as keyof ActiveHours, + index, + 'end', + e.target.value + ) + } + /> +
+ ))} +
+ ) + )} +
+
+ + {/* Task Creation Form */} + + + Create a new task + + Add tasks with their duration and an optional deadline. + + + +
+ + setNewTaskName(e.target.value)} + /> +
+
+ + + setNewTaskDuration(parseInt(e.target.value, 10)) + } + /> +
+
+ + setNewTaskDeadline(e.target.value)} + /> +
+
+ + + +
+
+ + {/* Column 2: Task List and Scheduling */} +
+ + + Task List + + Your current list of tasks to be scheduled. Click schedule when + ready. + + + +
+ {tasks.map((task) => ( +
+ + updateTask(task.id, { name: e.target.value }) + } + /> + + updateTask(task.id, { + duration: parseInt(e.target.value, 10), + }) + } + /> + + updateTask(task.id, { + deadline: e.target.value + ? dayjs(e.target.value) + : undefined, + }) + } + /> + +
+ ))} +
+
+ + + +
+
+ + {/* Column 3: Schedule and Logs */} +
+ {/* Scheduled Events */} + {events.length > 0 && ( + + + Your Scheduled Day + + Here is your optimized schedule. + + + + {Object.entries(groupedEvents).map(([date, dailyEvents]) => ( +
+

+ {dayjs(date).format('dddd, MMMM D')} +

+
    + {dailyEvents.map((event) => ( +
  • + {event.isPastDeadline && ( + + )} + {event.name}:{' '} + {event.range.start.format('HH:mm')} -{' '} + {event.range.end.format('HH:mm')} +
  • + ))} +
+
+ ))} +
+
+ )} + + {/* Logs */} + {logs.length > 0 && ( + + + Logs + + Warnings and errors from the scheduling process. + + + + {logs.map((log, index) => ( + + {log.type === 'warning' && } + {log.type === 'error' && ( + + )} + + {log.type.charAt(0).toUpperCase() + log.type.slice(1)} + + {log.message} + + ))} + + + )} +
+
+
+ ); +} + +export default SchedulerPage; diff --git a/apps/calendar/src/app/[locale]/layout.tsx b/apps/calendar/src/app/[locale]/layout.tsx index 313b1bd615..0b01bcd863 100644 --- a/apps/calendar/src/app/[locale]/layout.tsx +++ b/apps/calendar/src/app/[locale]/layout.tsx @@ -113,7 +113,7 @@ export default async function RootLayout({ children, params }: Props) { diff --git a/packages/ai/src/scheduling/algorithm.ts b/packages/ai/src/scheduling/algorithm.ts index 6befbaba18..39838cee47 100644 --- a/packages/ai/src/scheduling/algorithm.ts +++ b/packages/ai/src/scheduling/algorithm.ts @@ -1,29 +1,41 @@ import dayjs from 'dayjs'; -interface DateRange { +export interface DateRange { start: dayjs.Dayjs; end: dayjs.Dayjs; } -interface Event { +export interface Event { id: string; name: string; range: DateRange; + isPastDeadline?: boolean; } -interface Task { +export interface Task { id: string; name: string; duration: number; events: Event[]; + deadline?: dayjs.Dayjs; } -interface ActiveHours { +export interface ActiveHours { personal: DateRange[]; work: DateRange[]; meeting: DateRange[]; } +export interface Log { + type: 'warning' | 'error'; + message: string; +} + +export interface ScheduleResult { + events: Event[]; + logs: Log[]; +} + export const defaultActiveHours: ActiveHours = { personal: [ { @@ -54,4 +66,101 @@ export const defaultTasks: Task[] = [ }, ]; -// export const schedule = (events: Event[], tasks: Task[]) => {}; +export const scheduleTasks = ( + tasks: Task[], + activeHours: ActiveHours = defaultActiveHours +): ScheduleResult => { + const scheduledEvents: Event[] = []; + const logs: Log[] = []; + + // Sort tasks by deadline, earliest first. Tasks without a deadline are considered last. + const sortedTasks = [...tasks].sort((a, b) => { + if (a.deadline && b.deadline) + return a.deadline.isBefore(b.deadline) ? -1 : 1; + if (a.deadline) return -1; // a has a deadline, b doesn't + if (b.deadline) return 1; // b has a deadline, a doesn't + return 0; // no deadlines + }); + + const workHours = activeHours.work[0]; + + if (!workHours) { + logs.push({ + type: 'error', + message: 'No work hours defined to schedule tasks.', + }); + return { events: [], logs }; + } + + let availableTime = dayjs().isAfter(workHours.start) + ? dayjs() + : workHours.start.clone(); + + for (const task of sortedTasks) { + let taskStart = availableTime.clone(); + + const workDayEnd = workHours.end + .year(taskStart.year()) + .month(taskStart.month()) + .date(taskStart.date()); + const workDayStart = workHours.start + .year(taskStart.year()) + .month(taskStart.month()) + .date(taskStart.date()); + + if (taskStart.isAfter(workDayEnd)) { + taskStart = workDayStart.add(1, 'day'); + } + + if (taskStart.isBefore(workDayStart)) { + taskStart = workDayStart; + } + + let taskEnd = taskStart.add(task.duration, 'hour'); + + if (taskEnd.isAfter(workDayEnd)) { + logs.push({ + type: 'warning', + message: `Task "${task.name}" does not fit into the remaining time of the day. Moving to the next available slot.`, + }); + + taskStart = workDayStart.add(1, 'day'); + taskEnd = taskStart.add(task.duration, 'hour'); + + const nextWorkDayEnd = workHours.end + .year(taskStart.year()) + .month(taskStart.month()) + .date(taskStart.date()); + if (taskEnd.isAfter(nextWorkDayEnd)) { + logs.push({ + type: 'error', + message: `Task "${task.name}" (${task.duration}h) is longer than a single work day and could not be scheduled properly.`, + }); + } + } + + const newEvent: Event = { + id: `event-${task.id}`, + name: task.name, + range: { start: taskStart, end: taskEnd }, + isPastDeadline: false, + }; + + if (task.deadline && taskEnd.isAfter(task.deadline)) { + newEvent.isPastDeadline = true; + logs.push({ + type: 'warning', + message: `Task "${ + task.name + }" is scheduled past its deadline of ${task.deadline.format( + 'YYYY-MM-DD HH:mm' + )}.`, + }); + } + + scheduledEvents.push(newEvent); + availableTime = taskEnd; + } + + return { events: scheduledEvents, logs }; +}; diff --git a/packages/types/src/supabase.ts b/packages/types/src/supabase.ts index e545492408..f8650e474e 100644 --- a/packages/types/src/supabase.ts +++ b/packages/types/src/supabase.ts @@ -7348,41 +7348,41 @@ export type Database = { generate_cross_app_token: { Args: | { + p_user_id: string; p_expiry_seconds?: number; p_target_app: string; p_origin_app: string; - p_user_id: string; } | { - p_origin_app: string; - p_target_app: string; - p_expiry_seconds?: number; - p_session_data?: Json; p_user_id: string; + p_session_data?: Json; + p_expiry_seconds?: number; + p_target_app: string; + p_origin_app: string; }; Returns: string; }; get_challenge_stats: { - Args: { user_id_param: string; challenge_id_param: string }; + Args: { challenge_id_param: string; user_id_param: string }; Returns: { problems_attempted: number; total_score: number; }[]; }; get_daily_income_expense: { - Args: { _ws_id: string; past_days?: number }; + Args: { past_days?: number; _ws_id: string }; Returns: { - total_expense: number; - day: string; total_income: number; + day: string; + total_expense: number; }[]; }; get_daily_prompt_completion_tokens: { Args: { past_days?: number }; Returns: { + total_completion_tokens: number; total_prompt_tokens: number; day: string; - total_completion_tokens: number; }[]; }; get_finance_invoices_count: { @@ -7408,8 +7408,8 @@ export type Database = { get_hourly_prompt_completion_tokens: { Args: { past_hours?: number }; Returns: { - total_prompt_tokens: number; total_completion_tokens: number; + total_prompt_tokens: number; hour: string; }[]; }; @@ -7424,20 +7424,20 @@ export type Database = { get_inventory_products: { Args: { _has_unit?: boolean; - _warehouse_ids?: string[]; - _ws_id?: string; _category_ids?: string[]; + _ws_id?: string; + _warehouse_ids?: string[]; }; Returns: { - ws_id: string; + name: string; + manufacturer: string; amount: number; price: number; + id: string; category: string; unit_id: string; unit: string; - manufacturer: string; - name: string; - id: string; + ws_id: string; created_at: string; }[]; }; @@ -7458,19 +7458,19 @@ export type Database = { Returns: number; }; get_monthly_income_expense: { - Args: { past_months?: number; _ws_id: string }; + Args: { _ws_id: string; past_months?: number }; Returns: { - month: string; total_income: number; total_expense: number; + month: string; }[]; }; get_monthly_prompt_completion_tokens: { Args: { past_months?: number }; Returns: { - total_prompt_tokens: number; - month: string; total_completion_tokens: number; + month: string; + total_prompt_tokens: number; }[]; }; get_pending_event_participants: { @@ -7478,81 +7478,81 @@ export type Database = { Returns: number; }; get_possible_excluded_groups: { - Args: { _ws_id: string; included_groups: string[] }; + Args: { included_groups: string[]; _ws_id: string }; Returns: { - id: string; - name: string; ws_id: string; + name: string; + id: string; amount: number; }[]; }; get_possible_excluded_tags: { Args: { included_tags: string[]; _ws_id: string }; Returns: { + name: string; id: string; amount: number; ws_id: string; - name: string; }[]; }; get_session_statistics: { Args: Record; Returns: { - completed_count: number; - active_count: number; - unique_users_count: number; latest_session_date: string; total_count: number; + unique_users_count: number; + active_count: number; + completed_count: number; }[]; }; get_session_templates: { Args: { - limit_count?: number; - user_id_param: string; workspace_id: string; + user_id_param: string; + limit_count?: number; }; Returns: { - task_name: string; - avg_duration: number; - tags: string[]; category_name: string; - category_color: string; - usage_count: number; + tags: string[]; + description: string; task_id: string; category_id: string; - description: string; title: string; + task_name: string; + category_color: string; last_used: string; + avg_duration: number; + usage_count: number; }[]; }; get_submission_statistics: { Args: Record; Returns: { - unique_users_count: number; - latest_submission_date: string; total_count: number; + latest_submission_date: string; + unique_users_count: number; }[]; }; get_transaction_categories_with_amount: { Args: Record; Returns: { - ws_id: string; id: string; - name: string; - is_expense: boolean; - created_at: string; amount: number; + created_at: string; + ws_id: string; + is_expense: boolean; + name: string; }[]; }; get_user_role: { - Args: { ws_id: string; user_id: string }; + Args: { user_id: string; ws_id: string }; Returns: string; }; get_user_session_stats: { Args: { user_id: string }; Returns: { - active_sessions: number; current_session_age: unknown; + active_sessions: number; total_sessions: number; }[]; }; @@ -7560,25 +7560,25 @@ export type Database = { Args: { user_id: string }; Returns: { created_at: string; - session_id: string; is_current: boolean; ip: string; user_agent: string; updated_at: string; + session_id: string; }[]; }; get_user_tasks: { Args: { _board_id: string }; Returns: { - id: string; - list_id: string; board_id: string; - start_date: string; + list_id: string; end_date: string; - name: string; - description: string; - priority: number; + start_date: string; completed: boolean; + priority: number; + description: string; + name: string; + id: string; }[]; }; get_workspace_drive_size: { @@ -7594,24 +7594,24 @@ export type Database = { Returns: number; }; get_workspace_transactions_count: { - Args: { start_date?: string; ws_id: string; end_date?: string }; + Args: { end_date?: string; start_date?: string; ws_id: string }; Returns: number; }; get_workspace_user_groups: { Args: { - search_query: string; excluded_tags: string[]; included_tags: string[]; _ws_id: string; + search_query: string; }; Returns: { - created_at: string; - tag_count: number; tags: string[]; - ws_id: string; - notes: string; - name: string; id: string; + name: string; + notes: string; + ws_id: string; + tag_count: number; + created_at: string; }[]; }; get_workspace_user_groups_count: { @@ -7626,13 +7626,8 @@ export type Database = { search_query: string; }; Returns: { - balance: number; - ws_id: string; - groups: string[]; - group_count: number; - updated_at: string; - id: string; avatar_url: string; + id: string; full_name: string; display_name: string; email: string; @@ -7641,11 +7636,16 @@ export type Database = { birthday: string; ethnicity: string; guardian: string; - created_at: string; address: string; national_id: string; - linked_users: Json; note: string; + balance: number; + ws_id: string; + groups: string[]; + group_count: number; + linked_users: Json; + created_at: string; + updated_at: string; }[]; }; get_workspace_users_count: { @@ -7661,11 +7661,11 @@ export type Database = { Returns: number; }; get_workspace_wallets_income: { - Args: { end_date?: string; ws_id: string; start_date?: string }; + Args: { start_date?: string; end_date?: string; ws_id: string }; Returns: number; }; has_other_owner: { - Args: { _ws_id: string; _user_id: string }; + Args: { _user_id: string; _ws_id: string }; Returns: boolean; }; insert_ai_chat_message: { @@ -7693,7 +7693,7 @@ export type Database = { Returns: boolean; }; is_nova_user_id_in_team: { - Args: { _user_id: string; _team_id: string }; + Args: { _team_id: string; _user_id: string }; Returns: boolean; }; is_org_member: { @@ -7721,7 +7721,7 @@ export type Database = { Returns: Json; }; nova_get_challenge_with_user_stats: { - Args: { user_id: string; challenge_id: string }; + Args: { challenge_id: string; user_id: string }; Returns: Json; }; nova_get_user_daily_sessions: { @@ -7741,7 +7741,7 @@ export type Database = { Returns: number; }; revoke_user_session: { - Args: { target_user_id: string; session_id: string }; + Args: { session_id: string; target_user_id: string }; Returns: boolean; }; search_users: { @@ -7755,13 +7755,13 @@ export type Database = { enabled_filter?: boolean; }; Returns: { - created_at: string; id: string; display_name: string; deleted: boolean; avatar_url: string; handle: string; bio: string; + created_at: string; user_id: string; enabled: boolean; allow_challenge_management: boolean; @@ -7780,10 +7780,10 @@ export type Database = { search_query: string; }; Returns: { - display_name: string; id: string; relevance: number; avatar_url: string; + display_name: string; handle: string; }[]; }; @@ -7794,7 +7794,7 @@ export type Database = { }[]; }; transactions_have_same_abs_amount: { - Args: { transaction_id_2: string; transaction_id_1: string }; + Args: { transaction_id_1: string; transaction_id_2: string }; Returns: boolean; }; transactions_have_same_amount: { @@ -7806,18 +7806,18 @@ export type Database = { Returns: undefined; }; update_session_total_score: { - Args: { user_id_param: string; challenge_id_param: string }; + Args: { challenge_id_param: string; user_id_param: string }; Returns: undefined; }; validate_cross_app_token: { - Args: { p_token: string; p_target_app: string }; + Args: { p_target_app: string; p_token: string }; Returns: string; }; validate_cross_app_token_with_session: { Args: { p_token: string; p_target_app: string }; Returns: { - user_id: string; session_data: Json; + user_id: string; }[]; }; }; From ef724229c29ff0063e5b2881d924433be00f0324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=B5=20Ho=C3=A0ng=20Ph=C3=BAc?= Date: Tue, 17 Jun 2025 10:45:25 +0700 Subject: [PATCH 3/5] feat(scheduler): enhance scheduling functionality with new components and improved task management --- .../src/app/[locale]/(root)/navbar.tsx | 1 + .../components/AlgorithmInsights.tsx | 406 ++++++++++ .../scheduler/components/ScheduleDisplay.tsx | 303 +++++++ .../(root)/scheduler/components/TaskList.tsx | 474 +++++++++++ .../(root)/scheduler/components/TaskModal.tsx | 354 +++++++++ .../components/TemplateScenarios.tsx | 143 ++++ .../app/[locale]/(root)/scheduler/page.tsx | 537 +++++++------ packages/ai/src/scheduling/algorithm.test.ts | 2 +- packages/ai/src/scheduling/algorithm.ts | 449 ++++++++--- packages/ai/src/scheduling/default.ts | 25 + packages/ai/src/scheduling/templates.ts | 737 ++++++++++++++++++ packages/ai/src/scheduling/types.ts | 50 ++ 12 files changed, 3096 insertions(+), 385 deletions(-) create mode 100644 apps/calendar/src/app/[locale]/(root)/scheduler/components/AlgorithmInsights.tsx create mode 100644 apps/calendar/src/app/[locale]/(root)/scheduler/components/ScheduleDisplay.tsx create mode 100644 apps/calendar/src/app/[locale]/(root)/scheduler/components/TaskList.tsx create mode 100644 apps/calendar/src/app/[locale]/(root)/scheduler/components/TaskModal.tsx create mode 100644 apps/calendar/src/app/[locale]/(root)/scheduler/components/TemplateScenarios.tsx create mode 100644 packages/ai/src/scheduling/default.ts create mode 100644 packages/ai/src/scheduling/templates.ts create mode 100644 packages/ai/src/scheduling/types.ts diff --git a/apps/calendar/src/app/[locale]/(root)/navbar.tsx b/apps/calendar/src/app/[locale]/(root)/navbar.tsx index 97af4d7f39..218457a963 100644 --- a/apps/calendar/src/app/[locale]/(root)/navbar.tsx +++ b/apps/calendar/src/app/[locale]/(root)/navbar.tsx @@ -60,6 +60,7 @@ export default async function Navbar({ } separator={} onlyOnMobile={onlyOnMobile} + className="bg-background" /> ); } diff --git a/apps/calendar/src/app/[locale]/(root)/scheduler/components/AlgorithmInsights.tsx b/apps/calendar/src/app/[locale]/(root)/scheduler/components/AlgorithmInsights.tsx new file mode 100644 index 0000000000..375b13e6e9 --- /dev/null +++ b/apps/calendar/src/app/[locale]/(root)/scheduler/components/AlgorithmInsights.tsx @@ -0,0 +1,406 @@ +'use client'; + +import type { Event, Log, Task } from '@tuturuuu/ai/scheduling/types'; +import { Alert, AlertDescription, AlertTitle } from '@tuturuuu/ui/alert'; +import { Badge } from '@tuturuuu/ui/badge'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@tuturuuu/ui/card'; +import { Progress } from '@tuturuuu/ui/progress'; +import { Separator } from '@tuturuuu/ui/separator'; +import { + BrainIcon, + CheckCircleIcon, + InfoIcon, + LayersIcon, + TargetIcon, + TrendingUpIcon, + XCircleIcon, + ZapIcon, +} from 'lucide-react'; +import { useMemo } from 'react'; + +interface AlgorithmInsightsProps { + tasks: Task[]; + events: Event[]; + logs: Log[]; +} + +interface SchedulingMetrics { + totalTasks: number; + scheduledTasks: number; + totalDuration: number; + scheduledDuration: number; + splitTasks: number; + deadlinesMet: number; + deadlinesMissed: number; + categoryDistribution: Record; + averageTaskSize: number; + largestGap: number; + utilizationRate: number; +} + +const getCategoryColor = (category: string) => { + switch (category) { + case 'work': + return 'bg-dynamic-blue/10 text-dynamic-blue border-dynamic-blue/30'; + case 'personal': + return 'bg-dynamic-green/10 text-dynamic-green border-dynamic-green/30'; + case 'meeting': + return 'bg-dynamic-orange/10 text-dynamic-orange border-dynamic-orange/30'; + default: + return 'bg-dynamic-gray/10 text-dynamic-gray border-dynamic-gray/30'; + } +}; + +export function AlgorithmInsights({ + tasks, + events, + logs, +}: AlgorithmInsightsProps) { + const metrics = useMemo((): SchedulingMetrics => { + const scheduledTaskIds = new Set(events.map((e) => e.taskId)); + const scheduledTasks = tasks.filter((t) => scheduledTaskIds.has(t.id)); + + const totalDuration = tasks.reduce((sum, task) => sum + task.duration, 0); + const scheduledDuration = events.reduce( + (sum, event) => + sum + event.range.end.diff(event.range.start, 'hour', true), + 0 + ); + + const splitTasks = new Set( + events + .filter((e) => e.partNumber && e.partNumber > 1) + .map((e) => e.taskId) + ).size; + + let deadlinesMet = 0; + let deadlinesMissed = 0; + + tasks.forEach((task) => { + if (task.deadline) { + const taskEvents = events.filter((e) => e.taskId === task.id); + const lastEvent = taskEvents.sort((a, b) => + b.range.end.diff(a.range.end) + )[0]; + + if (lastEvent) { + if (lastEvent.range.end.isAfter(task.deadline)) { + deadlinesMissed++; + } else { + deadlinesMet++; + } + } + } + }); + + const categoryDistribution = tasks.reduce( + (acc, task) => { + acc[task.category] = (acc[task.category] || 0) + 1; + return acc; + }, + {} as Record + ); + + const averageTaskSize = totalDuration / tasks.length || 0; + + // Calculate utilization rate (simplified) + const workingHours = 8; // Assume 8-hour work day + const utilizationRate = Math.min( + (scheduledDuration / workingHours) * 100, + 100 + ); + + return { + totalTasks: tasks.length, + scheduledTasks: scheduledTasks.length, + totalDuration, + scheduledDuration, + splitTasks, + deadlinesMet, + deadlinesMissed, + categoryDistribution, + averageTaskSize, + largestGap: 0, // TODO: Calculate actual gaps + utilizationRate, + }; + }, [tasks, events]); + + const algorithmConsiderations = useMemo(() => { + const considerations = []; + + // Deadline prioritization + const tasksWithDeadlines = tasks.filter((t) => t.deadline).length; + if (tasksWithDeadlines > 0) { + considerations.push({ + icon: TargetIcon, + title: 'Deadline Prioritization', + description: `${tasksWithDeadlines} tasks have deadlines. Algorithm prioritizes these tasks based on urgency.`, + impact: 'high', + }); + } + + // Task splitting analysis + const splittableTasks = tasks.filter( + (t) => t.maxDuration < t.duration + ).length; + if (splittableTasks > 0) { + considerations.push({ + icon: ZapIcon, + title: 'Task Splitting Strategy', + description: `${splittableTasks} tasks can be split. Algorithm balances between focus time and flexibility.`, + impact: 'medium', + }); + } + + // Category time management + const categories = Object.keys(metrics.categoryDistribution); + if (categories.length > 1) { + considerations.push({ + icon: LayersIcon, + title: 'Category-Based Scheduling', + description: `Tasks span ${categories.length} categories. Each category respects its specific time constraints.`, + impact: 'medium', + }); + } + + // Utilization optimization + considerations.push({ + icon: TrendingUpIcon, + title: 'Time Utilization', + description: `Current schedule achieves ${metrics.utilizationRate.toFixed(1)}% utilization of available time.`, + impact: metrics.utilizationRate > 80 ? 'high' : 'medium', + }); + + // Constraint satisfaction + const constraintViolations = logs.filter( + (log) => log.type === 'error' + ).length; + if (constraintViolations === 0) { + considerations.push({ + icon: CheckCircleIcon, + title: 'Constraint Satisfaction', + description: + 'All scheduling constraints are satisfied without conflicts.', + impact: 'high', + }); + } + + return considerations; + }, [tasks, metrics, logs]); + + if (tasks.length === 0) { + return ( + + + + + Algorithm Insights + + + Detailed analysis of scheduling decisions and optimizations + + + +
+ +

Add tasks and generate a schedule to see algorithm insights.

+
+
+
+ ); + } + + return ( +
+ {/* Algorithm Considerations */} + + + + + Algorithm Considerations + + + How the scheduling algorithm approaches your task arrangement + + + + {algorithmConsiderations.map((consideration, index) => { + const IconComponent = consideration.icon; + const impactColor = + consideration.impact === 'high' + ? 'text-dynamic-green' + : consideration.impact === 'medium' + ? 'text-dynamic-orange' + : 'text-dynamic-blue'; + + return ( +
+ +
+
+

{consideration.title}

+ + {consideration.impact} impact + +
+

+ {consideration.description} +

+
+
+ ); + })} +
+
+ + {/* Scheduling Metrics */} + + + Scheduling Metrics + + Quantitative analysis of your schedule optimization + + + + {/* Overview Stats */} +
+
+
+ {metrics.scheduledTasks}/{metrics.totalTasks} +
+
+ Tasks Scheduled +
+
+
+
+ {metrics.scheduledDuration.toFixed(1)}h +
+
+ Time Scheduled +
+
+
+
+ {metrics.splitTasks} +
+
Split Tasks
+
+
+
+ {metrics.utilizationRate.toFixed(0)}% +
+
Utilization
+
+
+ + + + {/* Deadline Performance */} + {(metrics.deadlinesMet > 0 || metrics.deadlinesMissed > 0) && ( +
+

+ + Deadline Performance +

+
+
+ Deadlines Met + + {metrics.deadlinesMet} + +
+
+ Deadlines Missed + + {metrics.deadlinesMissed} + +
+ +
+
+ )} + + {/* Category Distribution */} +
+

+ + Category Distribution +

+
+ {Object.entries(metrics.categoryDistribution).map( + ([category, count]) => ( +
+
+ + {category} + +
+ {count} tasks +
+ ) + )} +
+
+
+
+ + {/* Logs and Warnings */} + {logs.length > 0 && ( + + + + + Scheduling Insights & Warnings + + + Detailed feedback from the scheduling process + + + +
+ {logs.map((log, index) => ( + + {log.type === 'warning' && } + {log.type === 'error' && } + + {log.type.charAt(0).toUpperCase() + log.type.slice(1)} + + + {log.message} + + + ))} +
+
+
+ )} +
+ ); +} diff --git a/apps/calendar/src/app/[locale]/(root)/scheduler/components/ScheduleDisplay.tsx b/apps/calendar/src/app/[locale]/(root)/scheduler/components/ScheduleDisplay.tsx new file mode 100644 index 0000000000..738a0327af --- /dev/null +++ b/apps/calendar/src/app/[locale]/(root)/scheduler/components/ScheduleDisplay.tsx @@ -0,0 +1,303 @@ +'use client'; + +import type { Event } from '@tuturuuu/ai/scheduling/types'; +import { Badge } from '@tuturuuu/ui/badge'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@tuturuuu/ui/card'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { + AlertTriangleIcon, + CalendarIcon, + ClockIcon, + SparklesIcon, + TrendingUpIcon, +} from 'lucide-react'; +import { useMemo } from 'react'; + +dayjs.extend(relativeTime); + +interface ScheduleDisplayProps { + events: Event[]; +} + +const getCategoryColor = (taskId: string) => { + // We can't get category from events directly, so we'll use a simple color scheme based on task ID + const hash = taskId.split('').reduce((a, b) => { + a = (a << 5) - a + b.charCodeAt(0); + return a & a; + }, 0); + + const colors = [ + 'bg-dynamic-blue/10 text-dynamic-blue border-dynamic-blue/30', + 'bg-dynamic-green/10 text-dynamic-green border-dynamic-green/30', + 'bg-dynamic-orange/10 text-dynamic-orange border-dynamic-orange/30', + 'bg-dynamic-purple/10 text-dynamic-purple border-dynamic-purple/30', + 'bg-dynamic-red/10 text-dynamic-red border-dynamic-red/30', + ]; + + return colors[Math.abs(hash) % colors.length]; +}; + +export function ScheduleDisplay({ events }: ScheduleDisplayProps) { + const groupedEvents = useMemo(() => { + return events.reduce( + (acc, event) => { + const date = event.range.start.format('YYYY-MM-DD'); + if (!acc[date]) { + acc[date] = []; + } + acc[date].push(event); + return acc; + }, + {} as Record + ); + }, [events]); + + const scheduleStats = useMemo(() => { + const totalDuration = events.reduce( + (sum, event) => + sum + event.range.end.diff(event.range.start, 'hour', true), + 0 + ); + + const uniqueTasks = new Set(events.map((e) => e.taskId)).size; + const splitTasks = new Set( + events + .filter((e) => e.partNumber && e.partNumber > 1) + .map((e) => e.taskId) + ).size; + + const overdueEvents = events.filter((e) => e.isPastDeadline).length; + + return { + totalDuration, + uniqueTasks, + splitTasks, + overdueEvents, + daysSpanned: Object.keys(groupedEvents).length, + }; + }, [events, groupedEvents]); + + if (events.length === 0) { + return ( + + + + + Your Schedule + + + Optimized task schedule with intelligent splitting + + + +
+ +

+ No Schedule Generated +

+

+ Add tasks and click "Generate Schedule" to see your + optimized timeline +

+
+
+
+ ); + } + + return ( +
+ {/* Schedule Overview */} + + + + + Schedule Overview + + Summary of your optimized schedule + + +
+
+
+ {events.length} +
+
Events
+
+
+
+ {scheduleStats.uniqueTasks} +
+
Tasks
+
+
+
+ {scheduleStats.totalDuration.toFixed(1)}h +
+
Total Time
+
+
+
+ {scheduleStats.splitTasks} +
+
Split Tasks
+
+
+
+ {scheduleStats.daysSpanned} +
+
Days
+
+
+
+
+ + {/* Schedule Timeline */} + + + + + Schedule Timeline + + + Your tasks organized by day and time + + + +
+ {Object.entries(groupedEvents) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, dailyEvents]) => { + const sortedEvents = dailyEvents.sort((a, b) => + a.range.start.diff(b.range.start) + ); + + const dayDuration = dailyEvents.reduce( + (sum, event) => + sum + event.range.end.diff(event.range.start, 'hour', true), + 0 + ); + + return ( +
+ {/* Day Header */} +
+
+

+ {dayjs(date).format('dddd, MMMM D')} +

+

+ {dailyEvents.length} events • {dayDuration.toFixed(1)}{' '} + hours +

+
+ + {dayjs(date).fromNow()} + +
+ + {/* Events Timeline */} +
+ {sortedEvents.map((event, eventIndex) => { + const duration = event.range.end.diff( + event.range.start, + 'hour', + true + ); + const nextEvent = sortedEvents[eventIndex + 1]; + const gap = nextEvent + ? nextEvent.range.start.diff( + event.range.end, + 'minute' + ) + : 0; + + return ( +
+ {/* Event Card */} +
+
+
+ {/* Event Header */} +
+ {event.isPastDeadline && ( + + + + + +

+ This task is scheduled past its + deadline +

+
+
+ )} +

+ {event.name} +

+ {event.partNumber && ( + + Part {event.partNumber}/ + {event.totalParts} + + )} +
+ + {/* Event Details */} +
+ + + {event.range.start.format('HH:mm')} -{' '} + {event.range.end.format('HH:mm')} + + {duration.toFixed(1)}h + + {event.taskId.split('-')[0]} + +
+
+
+
+ + {/* Gap Indicator */} + {gap > 0 && ( +
+
+ + {gap} minute break +
+
+ )} +
+ ); + })} +
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/apps/calendar/src/app/[locale]/(root)/scheduler/components/TaskList.tsx b/apps/calendar/src/app/[locale]/(root)/scheduler/components/TaskList.tsx new file mode 100644 index 0000000000..5c9513b4e9 --- /dev/null +++ b/apps/calendar/src/app/[locale]/(root)/scheduler/components/TaskList.tsx @@ -0,0 +1,474 @@ +'use client'; + +import type { Event, Task } from '@tuturuuu/ai/scheduling/types'; +import { Badge } from '@tuturuuu/ui/badge'; +import { Button } from '@tuturuuu/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@tuturuuu/ui/card'; +import { Input } from '@tuturuuu/ui/input'; +import { Label } from '@tuturuuu/ui/label'; +import { Progress } from '@tuturuuu/ui/progress'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@tuturuuu/ui/select'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip'; +import dayjs from 'dayjs'; +import { + CalendarIcon, + CheckCircleIcon, + ClockIcon, + PlusIcon, + Trash2Icon, + ZapIcon, +} from 'lucide-react'; +import { useMemo } from 'react'; + +interface TaskListProps { + tasks: Task[]; + events: Event[]; + isScheduling: boolean; + onAddTask: () => void; + onUpdateTask: (id: string, updates: Partial) => void; + onDeleteTask: (id: string) => void; + onSchedule: () => void; +} + +const getCategoryColor = (category: 'work' | 'personal' | 'meeting') => { + switch (category) { + case 'work': + return 'bg-dynamic-blue/10 text-dynamic-blue border-dynamic-blue/30'; + case 'personal': + return 'bg-dynamic-green/10 text-dynamic-green border-dynamic-green/30'; + case 'meeting': + return 'bg-dynamic-orange/10 text-dynamic-orange border-dynamic-orange/30'; + default: + return 'bg-dynamic-gray/10 text-dynamic-gray border-dynamic-gray/30'; + } +}; + +const getCategoryIcon = (category: 'work' | 'personal' | 'meeting') => { + switch (category) { + case 'work': + return '💼'; + case 'personal': + return '🏠'; + case 'meeting': + return '👥'; + default: + return '📋'; + } +}; + +export function TaskList({ + tasks, + events, + isScheduling, + onAddTask, + onUpdateTask, + onDeleteTask, + onSchedule, +}: TaskListProps) { + const taskProgress = useMemo(() => { + const progressMap = new Map< + string, + { completed: number; remaining: number } + >(); + + tasks.forEach((task) => { + const taskEvents = events.filter((event) => event.taskId === task.id); + const completedTime = taskEvents.reduce((sum, event) => { + return sum + event.range.end.diff(event.range.start, 'hour', true); + }, 0); + + progressMap.set(task.id, { + completed: completedTime, + remaining: Math.max(0, task.duration - completedTime), + }); + }); + + return progressMap; + }, [tasks, events]); + + const totalDuration = tasks.reduce((sum, task) => sum + task.duration, 0); + const completedTasks = tasks.filter((task) => { + const progress = taskProgress.get(task.id); + return progress && progress.remaining === 0; + }).length; + + const getDeadlineStatus = (deadline?: dayjs.Dayjs) => { + if (!deadline) return null; + + const now = dayjs(); + const hoursUntil = deadline.diff(now, 'hour', true); + + if (hoursUntil < 0) { + return { type: 'overdue', text: 'Overdue', color: 'text-destructive' }; + } else if (hoursUntil < 24) { + return { + type: 'urgent', + text: `${Math.round(hoursUntil)}h left`, + color: 'text-dynamic-orange', + }; + } else if (hoursUntil < 72) { + return { + type: 'soon', + text: `${Math.round(hoursUntil / 24)}d left`, + color: 'text-dynamic-yellow', + }; + } else { + return { + type: 'later', + text: deadline.format('MMM D'), + color: 'text-muted-foreground', + }; + } + }; + + return ( +
+ {/* Header with Stats */} + + +
+
+ + + Task Management + + + Organize and track your tasks with intelligent scheduling + +
+ +
+
+ +
+
+
+ {tasks.length} +
+
Total Tasks
+
+
+
+ {completedTasks} +
+
Completed
+
+
+
+ {totalDuration}h +
+
Total Time
+
+
+
+ {tasks.filter((t) => t.deadline).length} +
+
+ With Deadlines +
+
+
+
+
+ + {/* Tasks List */} + + + Your Tasks + + Manage task details and constraints for intelligent scheduling + + + + {tasks.length === 0 ? ( +
+ +

No Tasks Yet

+

+ Add some tasks or load a template to get started with scheduling +

+ +
+ ) : ( +
+ {tasks.map((task) => { + const progress = taskProgress.get(task.id); + const progressPercentage = progress + ? (progress.completed / task.duration) * 100 + : 0; + const deadlineStatus = getDeadlineStatus(task.deadline); + const isCompleted = progress?.remaining === 0; + + return ( +
+ {/* Task Header */} +
+
+
+ + {getCategoryIcon(task.category)} + + + onUpdateTask(task.id, { name: e.target.value }) + } + className={`h-auto border-none bg-transparent p-0 text-lg font-semibold focus-visible:ring-0 ${ + isCompleted + ? 'text-muted-foreground line-through' + : '' + }`} + /> + {isCompleted && ( + + )} +
+ + {/* Progress Bar */} + {progress && ( +
+
+ + Progress: {progress.completed.toFixed(1)}h /{' '} + {task.duration}h + + + {Math.round(progressPercentage)}% + +
+ + {progress.remaining > 0 && ( +
+ + + {progress.remaining.toFixed(1)}h remaining + +
+ )} +
+ )} + + {/* Tags */} +
+ + {task.category} + + + {deadlineStatus && ( + + + + + {deadlineStatus.text} + + + +

+ Deadline:{' '} + {task.deadline?.format('MMM D, YYYY HH:mm')} +

+
+
+ )} + + {task.maxDuration < task.duration && ( + + + + + Splittable + + + +

+ This task can be split into smaller chunks +

+
+
+ )} +
+
+ + +
+ + {/* Task Details */} +
+
+ + + onUpdateTask(task.id, { + duration: parseFloat(e.target.value), + }) + } + className="text-sm" + /> +
+ +
+ + + onUpdateTask(task.id, { + minDuration: parseFloat(e.target.value), + }) + } + className="text-sm" + /> +
+ +
+ + + onUpdateTask(task.id, { + maxDuration: parseFloat(e.target.value), + }) + } + className="text-sm" + /> +
+ +
+ + +
+
+ + {/* Deadline */} +
+ + + onUpdateTask(task.id, { + deadline: e.target.value + ? dayjs(e.target.value) + : undefined, + }) + } + className="text-sm" + min={dayjs().format('YYYY-MM-DDTHH:mm')} + /> +
+
+ ); + })} +
+ )} +
+ + {tasks.length > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/apps/calendar/src/app/[locale]/(root)/scheduler/components/TaskModal.tsx b/apps/calendar/src/app/[locale]/(root)/scheduler/components/TaskModal.tsx new file mode 100644 index 0000000000..d0066ff3a6 --- /dev/null +++ b/apps/calendar/src/app/[locale]/(root)/scheduler/components/TaskModal.tsx @@ -0,0 +1,354 @@ +'use client'; + +import type { Task } from '@tuturuuu/ai/scheduling/types'; +import { Button } from '@tuturuuu/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@tuturuuu/ui/dialog'; +import { Input } from '@tuturuuu/ui/input'; +import { Label } from '@tuturuuu/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@tuturuuu/ui/select'; +import { Separator } from '@tuturuuu/ui/separator'; +import { Textarea } from '@tuturuuu/ui/textarea'; +import dayjs from 'dayjs'; +import { CalendarIcon, ClockIcon, PlusIcon, TagIcon } from 'lucide-react'; +import { useState } from 'react'; + +interface TaskModalProps { + isOpen: boolean; + onClose: () => void; + onAddTask: (task: Omit) => void; +} + +const categoryOptions = [ + { + value: 'work', + label: 'Work', + icon: '💼', + description: 'Professional tasks and projects', + }, + { + value: 'personal', + label: 'Personal', + icon: '🏠', + description: 'Personal activities and hobbies', + }, + { + value: 'meeting', + label: 'Meeting', + icon: '👥', + description: 'Meetings and collaborative sessions', + }, +] as const; + +export function TaskModal({ isOpen, onClose, onAddTask }: TaskModalProps) { + const [formData, setFormData] = useState({ + name: '', + description: '', + duration: 1, + minDuration: 0.5, + maxDuration: 2, + category: 'work' as 'work' | 'personal' | 'meeting', + deadline: '', + priority: 'medium' as 'low' | 'medium' | 'high', + }); + + const [errors, setErrors] = useState>({}); + + const validateForm = () => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = 'Task name is required'; + } + + if (formData.duration <= 0) { + newErrors.duration = 'Duration must be greater than 0'; + } + + if (formData.minDuration <= 0) { + newErrors.minDuration = 'Minimum duration must be greater than 0'; + } + + if (formData.maxDuration <= 0) { + newErrors.maxDuration = 'Maximum duration must be greater than 0'; + } + + if (formData.minDuration > formData.maxDuration) { + newErrors.minDuration = 'Minimum duration cannot be greater than maximum'; + } + + if (formData.duration < formData.minDuration) { + newErrors.duration = 'Duration cannot be less than minimum duration'; + } + + if (formData.deadline && dayjs(formData.deadline).isBefore(dayjs())) { + newErrors.deadline = 'Deadline cannot be in the past'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + const newTask: Omit = { + name: formData.name, + duration: formData.duration, + minDuration: formData.minDuration, + maxDuration: formData.maxDuration, + category: formData.category, + deadline: formData.deadline ? dayjs(formData.deadline) : undefined, + }; + + onAddTask(newTask); + handleClose(); + }; + + const handleClose = () => { + setFormData({ + name: '', + description: '', + duration: 1, + minDuration: 0.5, + maxDuration: 2, + category: 'work', + deadline: '', + priority: 'medium', + }); + setErrors({}); + onClose(); + }; + + const updateFormData = (field: string, value: string | number) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: '' })); + } + }; + + return ( + + + + + + Add New Task + + + Create a new task with scheduling constraints and preferences. + + + +
+ {/* Basic Information */} +
+
+ + updateFormData('name', e.target.value)} + className={errors.name ? 'border-destructive' : ''} + /> + {errors.name && ( +

{errors.name}

+ )} +
+ +
+ +