diff --git a/apps/db/supabase/migrations/20250620145741_add_calendar_tasks_table.sql b/apps/db/supabase/migrations/20250620145741_add_calendar_tasks_table.sql new file mode 100644 index 000000000..5e5a853a5 --- /dev/null +++ b/apps/db/supabase/migrations/20250620145741_add_calendar_tasks_table.sql @@ -0,0 +1,98 @@ +create type "public"."calendar_task_time" as enum ('working_time', 'personal_time'); + +create type "public"."priority_status" as enum ('low', 'medium', 'high', 'critical'); + +create table "public"."workspace_calendar_tasks" ( + "id" uuid not null default gen_random_uuid(), + "created_at" timestamp with time zone not null default now(), + "ws_id" uuid, + "creator_id" uuid, + "updated_at" timestamp with time zone, + "is_splittable" boolean, + "name" text, + "min_split_duration_minutes" smallint, + "max_split_duration_minutes" smallint, + "schedule_after" timestamp with time zone, + "due_date" timestamp with time zone, + "time_reference" calendar_task_time default 'working_time'::calendar_task_time, + "user_defined_priority" priority_status default 'medium'::priority_status, + "evaluated_priority" priority_status default 'medium'::priority_status +); + + +alter table "public"."workspace_calendar_tasks" enable row level security; + +CREATE UNIQUE INDEX workspace_calendar_tasks_pkey ON public.workspace_calendar_tasks USING btree (id); + +alter table "public"."workspace_calendar_tasks" add constraint "workspace_calendar_tasks_pkey" PRIMARY KEY using index "workspace_calendar_tasks_pkey"; + +alter table "public"."workspace_calendar_tasks" add constraint "workspace_calendar_tasks_creator_id_fkey" FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE CASCADE not valid; + +alter table "public"."workspace_calendar_tasks" validate constraint "workspace_calendar_tasks_creator_id_fkey"; + +alter table "public"."workspace_calendar_tasks" add constraint "workspace_calendar_tasks_ws_id_fkey" FOREIGN KEY (ws_id) REFERENCES workspaces(id) ON DELETE CASCADE not valid; + +alter table "public"."workspace_calendar_tasks" validate constraint "workspace_calendar_tasks_ws_id_fkey"; + +grant delete on table "public"."workspace_calendar_tasks" to "anon"; + +grant insert on table "public"."workspace_calendar_tasks" to "anon"; + +grant references on table "public"."workspace_calendar_tasks" to "anon"; + +grant select on table "public"."workspace_calendar_tasks" to "anon"; + +grant trigger on table "public"."workspace_calendar_tasks" to "anon"; + +grant truncate on table "public"."workspace_calendar_tasks" to "anon"; + +grant update on table "public"."workspace_calendar_tasks" to "anon"; + +grant delete on table "public"."workspace_calendar_tasks" to "authenticated"; + +grant insert on table "public"."workspace_calendar_tasks" to "authenticated"; + +grant references on table "public"."workspace_calendar_tasks" to "authenticated"; + +grant select on table "public"."workspace_calendar_tasks" to "authenticated"; + +grant trigger on table "public"."workspace_calendar_tasks" to "authenticated"; + +grant truncate on table "public"."workspace_calendar_tasks" to "authenticated"; + +grant update on table "public"."workspace_calendar_tasks" to "authenticated"; + +grant delete on table "public"."workspace_calendar_tasks" to "service_role"; + +grant insert on table "public"."workspace_calendar_tasks" to "service_role"; + +grant references on table "public"."workspace_calendar_tasks" to "service_role"; + +grant select on table "public"."workspace_calendar_tasks" to "service_role"; + +grant trigger on table "public"."workspace_calendar_tasks" to "service_role"; + +grant truncate on table "public"."workspace_calendar_tasks" to "service_role"; + +grant update on table "public"."workspace_calendar_tasks" to "service_role"; + +alter table "public"."workspace_calendar_tasks" add column "description" text; + +alter table "public"."workspace_calendar_tasks" add column "total_duration" text not null; + +alter table "public"."workspace_calendar_tasks" alter column "max_split_duration_minutes" set data type real using "max_split_duration_minutes"::real; + +alter table "public"."workspace_calendar_tasks" alter column "min_split_duration_minutes" set data type real using "min_split_duration_minutes"::real; + +alter table "public"."workspace_calendar_tasks" enable row level security; + +create policy "allow only user in the workspace to insert" +on "public"."workspace_calendar_tasks" +as permissive +for insert +to authenticated +with check (true); + +alter table "public"."workspace_calendar_tasks" alter column "total_duration" set data type real using "total_duration"::real; + +alter table "public"."workspace_calendar_tasks" alter column "updated_at" set default now(); \ No newline at end of file diff --git a/apps/db/supabase/migrations/20250621070438_alter_tasks_table.sql b/apps/db/supabase/migrations/20250621070438_alter_tasks_table.sql new file mode 100644 index 000000000..ddb453391 --- /dev/null +++ b/apps/db/supabase/migrations/20250621070438_alter_tasks_table.sql @@ -0,0 +1,209 @@ +drop policy "Users can view sync logs for their workspaces" on "public"."workspace_calendar_sync_log"; + +drop policy "Workspace members can read and write whiteboards" on "public"."workspace_whiteboards"; + +revoke delete on table "public"."workspace_calendar_sync_log" from "anon"; + +revoke insert on table "public"."workspace_calendar_sync_log" from "anon"; + +revoke references on table "public"."workspace_calendar_sync_log" from "anon"; + +revoke select on table "public"."workspace_calendar_sync_log" from "anon"; + +revoke trigger on table "public"."workspace_calendar_sync_log" from "anon"; + +revoke truncate on table "public"."workspace_calendar_sync_log" from "anon"; + +revoke update on table "public"."workspace_calendar_sync_log" from "anon"; + +revoke delete on table "public"."workspace_calendar_sync_log" from "authenticated"; + +revoke insert on table "public"."workspace_calendar_sync_log" from "authenticated"; + +revoke references on table "public"."workspace_calendar_sync_log" from "authenticated"; + +revoke select on table "public"."workspace_calendar_sync_log" from "authenticated"; + +revoke trigger on table "public"."workspace_calendar_sync_log" from "authenticated"; + +revoke truncate on table "public"."workspace_calendar_sync_log" from "authenticated"; + +revoke update on table "public"."workspace_calendar_sync_log" from "authenticated"; + +revoke delete on table "public"."workspace_calendar_sync_log" from "service_role"; + +revoke insert on table "public"."workspace_calendar_sync_log" from "service_role"; + +revoke references on table "public"."workspace_calendar_sync_log" from "service_role"; + +revoke select on table "public"."workspace_calendar_sync_log" from "service_role"; + +revoke trigger on table "public"."workspace_calendar_sync_log" from "service_role"; + +revoke truncate on table "public"."workspace_calendar_sync_log" from "service_role"; + +revoke update on table "public"."workspace_calendar_sync_log" from "service_role"; + +revoke delete on table "public"."workspace_whiteboards" from "anon"; + +revoke insert on table "public"."workspace_whiteboards" from "anon"; + +revoke references on table "public"."workspace_whiteboards" from "anon"; + +revoke select on table "public"."workspace_whiteboards" from "anon"; + +revoke trigger on table "public"."workspace_whiteboards" from "anon"; + +revoke truncate on table "public"."workspace_whiteboards" from "anon"; + +revoke update on table "public"."workspace_whiteboards" from "anon"; + +revoke delete on table "public"."workspace_whiteboards" from "authenticated"; + +revoke insert on table "public"."workspace_whiteboards" from "authenticated"; + +revoke references on table "public"."workspace_whiteboards" from "authenticated"; + +revoke select on table "public"."workspace_whiteboards" from "authenticated"; + +revoke trigger on table "public"."workspace_whiteboards" from "authenticated"; + +revoke truncate on table "public"."workspace_whiteboards" from "authenticated"; + +revoke update on table "public"."workspace_whiteboards" from "authenticated"; + +revoke delete on table "public"."workspace_whiteboards" from "service_role"; + +revoke insert on table "public"."workspace_whiteboards" from "service_role"; + +revoke references on table "public"."workspace_whiteboards" from "service_role"; + +revoke select on table "public"."workspace_whiteboards" from "service_role"; + +revoke trigger on table "public"."workspace_whiteboards" from "service_role"; + +revoke truncate on table "public"."workspace_whiteboards" from "service_role"; + +revoke update on table "public"."workspace_whiteboards" from "service_role"; + +alter table "public"."workspace_calendar_sync_log" drop constraint "workspace_calendar_sync_log_status_check"; + +alter table "public"."workspace_calendar_sync_log" drop constraint "workspace_calendar_sync_log_timestamps_check"; + +alter table "public"."workspace_calendar_sync_log" drop constraint "workspace_calendar_sync_log_triggered_by_check"; + +alter table "public"."workspace_calendar_sync_log" drop constraint "workspace_calendar_sync_log_ws_id_fkey"; + +alter table "public"."workspace_calendar_tasks" drop constraint "workspace_calendar_tasks_creator_id_fkey"; + +alter table "public"."workspace_calendar_tasks" drop constraint "workspace_calendar_tasks_ws_id_fkey"; + +alter table "public"."workspace_whiteboards" drop constraint "workspace_whiteboards_creator_id_fkey"; + +alter table "public"."workspace_whiteboards" drop constraint "workspace_whiteboards_ws_id_fkey"; + +drop view if exists "public"."time_tracking_session_analytics"; + +alter table "public"."workspace_calendar_sync_log" drop constraint "workspace_calendar_sync_log_pkey"; + +alter table "public"."workspace_calendar_tasks" drop constraint "workspace_calendar_tasks_pkey"; + +alter table "public"."workspace_whiteboards" drop constraint "workspace_whiteboards_pkey"; + +drop index if exists "public"."idx_whiteboards_creator_id"; + +drop index if exists "public"."idx_whiteboards_snapshot_gin"; + +drop index if exists "public"."idx_whiteboards_ws_id"; + +drop index if exists "public"."workspace_calendar_sync_log_pkey"; + +drop index if exists "public"."workspace_calendar_sync_log_status_idx"; + +drop index if exists "public"."workspace_calendar_sync_log_sync_started_at_idx"; + +drop index if exists "public"."workspace_calendar_sync_log_workspace_id_idx"; + +drop index if exists "public"."workspace_calendar_tasks_pkey"; + +drop index if exists "public"."workspace_whiteboards_pkey"; + +drop table "public"."workspace_calendar_sync_log"; + +drop table "public"."workspace_whiteboards"; + +alter table "public"."tasks" add column "is_splittable" boolean not null default true; + +alter table "public"."tasks" add column "max_split_duration_minutes" real default '240'::real; + +alter table "public"."tasks" add column "min_split_duration_minutes" real default '30'::real; + +alter table "public"."tasks" add column "time_reference" calendar_task_time not null default 'working_time'::calendar_task_time; + +alter table "public"."tasks" add column "total_duration" real; + +alter table "public"."tasks" add column "user_defined_priority" priority_status default 'medium'::priority_status; + + + + +CREATE UNIQUE INDEX workspace_calendar_taskss_pkey ON public.workspace_calendar_tasks USING btree (id); + +alter table "public"."workspace_calendar_tasks" add constraint "workspace_calendar_taskss_pkey" PRIMARY KEY using index "workspace_calendar_taskss_pkey"; + +alter table "public"."workspace_calendar_tasks" add constraint "workspace_calendar_taskss_creator_id_fkey" FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE CASCADE not valid; + +alter table "public"."workspace_calendar_tasks" validate constraint "workspace_calendar_taskss_creator_id_fkey"; + +alter table "public"."workspace_calendar_tasks" add constraint "workspace_calendar_taskss_ws_id_fkey" FOREIGN KEY (ws_id) REFERENCES workspaces(id) ON DELETE CASCADE not valid; + +alter table "public"."workspace_calendar_tasks" validate constraint "workspace_calendar_taskss_ws_id_fkey"; + +create or replace view "public"."time_tracking_session_analytics" as SELECT tts.id, + tts.ws_id, + tts.user_id, + tts.task_id, + tts.category_id, + tts.title, + tts.description, + tts.start_time, + tts.end_time, + tts.duration_seconds, + tts.is_running, + tts.tags, + tts.created_at, + tts.updated_at, + tts.productivity_score, + tts.was_resumed, + ttc.name AS category_name, + ttc.color AS category_color, + t.name AS task_name, + EXTRACT(hour FROM tts.start_time) AS start_hour, + EXTRACT(dow FROM tts.start_time) AS day_of_week, + date_trunc('day'::text, tts.start_time) AS session_date, + date_trunc('week'::text, tts.start_time) AS session_week, + date_trunc('month'::text, tts.start_time) AS session_month, + CASE + WHEN (tts.duration_seconds >= 7200) THEN 'long'::text + WHEN (tts.duration_seconds >= 1800) THEN 'medium'::text + WHEN (tts.duration_seconds >= 300) THEN 'short'::text + ELSE 'micro'::text + END AS session_length_category + FROM ((time_tracking_sessions tts + LEFT JOIN time_tracking_categories ttc ON ((tts.category_id = ttc.id))) + LEFT JOIN tasks t ON ((tts.task_id = t.id))); + + + +alter table "public"."tasks" alter column "is_splittable" drop default; + +alter table "public"."tasks" alter column "is_splittable" drop not null; + +alter table "public"."tasks" alter column "max_split_duration_minutes" drop default; + +alter table "public"."tasks" alter column "min_split_duration_minutes" drop default; + +alter table "public"."tasks" alter column "time_reference" drop default; + +alter table "public"."tasks" alter column "time_reference" drop not null; + diff --git a/apps/db/supabase/migrations/20250621074218_new_migration.sql b/apps/db/supabase/migrations/20250621074218_new_migration.sql new file mode 100644 index 000000000..20ab0a00d --- /dev/null +++ b/apps/db/supabase/migrations/20250621074218_new_migration.sql @@ -0,0 +1,50 @@ + + + + + + + + +alter table "public"."tasks" alter column "list_id" drop not null; + + + + + +create or replace view "public"."time_tracking_session_analytics" as SELECT tts.id, + tts.ws_id, + tts.user_id, + tts.task_id, + tts.category_id, + tts.title, + tts.description, + tts.start_time, + tts.end_time, + tts.duration_seconds, + tts.is_running, + tts.tags, + tts.created_at, + tts.updated_at, + tts.productivity_score, + tts.was_resumed, + ttc.name AS category_name, + ttc.color AS category_color, + t.name AS task_name, + EXTRACT(hour FROM tts.start_time) AS start_hour, + EXTRACT(dow FROM tts.start_time) AS day_of_week, + date_trunc('day'::text, tts.start_time) AS session_date, + date_trunc('week'::text, tts.start_time) AS session_week, + date_trunc('month'::text, tts.start_time) AS session_month, + CASE + WHEN (tts.duration_seconds >= 7200) THEN 'long'::text + WHEN (tts.duration_seconds >= 1800) THEN 'medium'::text + WHEN (tts.duration_seconds >= 300) THEN 'short'::text + ELSE 'micro'::text + END AS session_length_category + FROM ((time_tracking_sessions tts + LEFT JOIN time_tracking_categories ttc ON ((tts.category_id = ttc.id))) + LEFT JOIN tasks t ON ((tts.task_id = t.id))); + + + diff --git a/apps/db/supabase/migrations/20250621082137_new_migration.sql b/apps/db/supabase/migrations/20250621082137_new_migration.sql new file mode 100644 index 000000000..3f0765d66 --- /dev/null +++ b/apps/db/supabase/migrations/20250621082137_new_migration.sql @@ -0,0 +1,41 @@ +drop view if exists "public"."time_tracking_session_analytics"; + + + + +create or replace view "public"."time_tracking_session_analytics" as SELECT tts.id, + tts.ws_id, + tts.user_id, + tts.task_id, + tts.category_id, + tts.title, + tts.description, + tts.start_time, + tts.end_time, + tts.duration_seconds, + tts.is_running, + tts.tags, + tts.created_at, + tts.updated_at, + tts.productivity_score, + tts.was_resumed, + ttc.name AS category_name, + ttc.color AS category_color, + t.name AS task_name, + EXTRACT(hour FROM tts.start_time) AS start_hour, + EXTRACT(dow FROM tts.start_time) AS day_of_week, + date_trunc('day'::text, tts.start_time) AS session_date, + date_trunc('week'::text, tts.start_time) AS session_week, + date_trunc('month'::text, tts.start_time) AS session_month, + CASE + WHEN (tts.duration_seconds >= 7200) THEN 'long'::text + WHEN (tts.duration_seconds >= 1800) THEN 'medium'::text + WHEN (tts.duration_seconds >= 300) THEN 'short'::text + ELSE 'micro'::text + END AS session_length_category + FROM ((time_tracking_sessions tts + LEFT JOIN time_tracking_categories ttc ON ((tts.category_id = ttc.id))) + LEFT JOIN tasks t ON ((tts.task_id = t.id))); + + + diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/client.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/client.tsx index 079c9afd0..4031eb845 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/client.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/client.tsx @@ -1,5 +1,7 @@ 'use client'; +import AddEventButton from './components/add-event-button'; +import AddEventModal from './components/add-event-dialog'; import AutoScheduleComprehensiveDialog from './components/auto-schedule-comprehensive-dialog'; import TestEventGeneratorButton from './components/test-event-generator-button'; import { DEV_MODE } from '@/constants/common'; @@ -10,6 +12,7 @@ import { Sparkles } from '@tuturuuu/ui/icons'; import { SmartCalendar } from '@tuturuuu/ui/legacy/calendar/smart-calendar'; import { ROOT_WORKSPACE_ID } from '@tuturuuu/utils/constants'; import { useLocale, useTranslations } from 'next-intl'; +import { useState } from 'react'; export default function CalendarClientPage({ experimentalGoogleToken, @@ -20,11 +23,16 @@ export default function CalendarClientPage({ }) { const t = useTranslations('calendar'); const locale = useLocale(); + const [isAddEventModalOpen, setIsAddEventModalOpen] = useState(false); + + const openAddEventDialog = () => setIsAddEventModalOpen(true); + const closeAddEventDialog = () => setIsAddEventModalOpen(false); const extras = workspace.id === ROOT_WORKSPACE_ID ? (
{DEV_MODE && } + + ); +} diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/add-event-dialog.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/add-event-dialog.tsx new file mode 100644 index 000000000..b2204b89d --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/calendar/components/add-event-dialog.tsx @@ -0,0 +1,512 @@ +import { createClient } from '@tuturuuu/supabase/next/client'; +import { Button } from '@tuturuuu/ui/button'; +import { Checkbox } from '@tuturuuu/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@tuturuuu/ui/dialog'; +import { CalendarIcon, ClockIcon, PlusIcon } from '@tuturuuu/ui/icons'; +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 React from 'react'; + +interface AddEventModalProps { + isOpen?: boolean; + onClose?: () => void; + wsId?: string; +} + +export default function AddEventModal({ + isOpen, + onClose, + wsId, +}: AddEventModalProps) { + const [formData, setFormData] = React.useState({ + name: '', + description: '', + total_duration: 1, + is_splittable: true, + min_split_duration_hours: 0.5, + max_split_duration_hours: 2, + time_reference: 'working_time', + start_date: '', + end_date: '', + }); + + const [errors, setErrors] = React.useState>({}); + const [isLoading, setIsLoading] = React.useState(false); + const [user, setUser] = React.useState(null); + const supabase = createClient(); + + React.useEffect(() => { + const getUser = async () => { + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + if (!error && user) { + setUser(user); + } + }; + + getUser(); + }, [supabase]); + + const validateForm = () => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = 'Task name is required'; + } + + if (formData.total_duration <= 0) { + newErrors.total_duration = 'Duration must be greater than 0'; + } + + if (formData.is_splittable) { + if (formData.min_split_duration_hours <= 0) { + newErrors.min_split_duration_hours = + 'Minimum duration must be greater than 0'; + } + + if (formData.max_split_duration_hours <= 0) { + newErrors.max_split_duration_hours = + 'Maximum duration must be greater than 0'; + } + + if ( + formData.min_split_duration_hours > formData.max_split_duration_hours + ) { + newErrors.min_split_duration_hours = + 'Minimum duration cannot be greater than maximum'; + } + } + + if (formData.end_date && dayjs(formData.end_date).isBefore(dayjs())) { + newErrors.end_date = 'End date cannot be in the past'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const updateFormData = (field: string, value: string | number | boolean) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: '' })); + } + }; + + const submitToDatabase = async () => { + if (!wsId) { + setErrors({ submit: 'Workspace ID is required' }); + return false; + } + + if (!user) { + setErrors({ submit: 'Authentication required. Please log in.' }); + return false; + } + + try { + setIsLoading(true); + + const taskData = { + name: formData.name.trim(), + description: formData.description.trim() || null, + total_duration: formData.total_duration, + is_splittable: formData.is_splittable, + min_split_duration_hours: formData.is_splittable + ? formData.min_split_duration_hours + : null, + max_split_duration_hours: formData.is_splittable + ? formData.max_split_duration_hours + : null, + time_reference: formData.time_reference as + | 'working_time' + | 'personal_time', + start_date: formData.start_date || null, + end_date: formData.end_date || null, + ws_id: wsId, + creator_id: user.id, + archived: false, + completed: false, + created_at: new Date().toISOString(), + }; + + const { data, error } = await supabase + .from('tasks') + .insert([taskData]) + .select(); + + if (error) { + console.error('Database error:', error); + setErrors({ submit: `Failed to create task: ${error.message}` }); + return false; + } + + console.log('Task created successfully:', data); + return true; + } catch (error) { + console.error('Unexpected error:', error); + setErrors({ submit: 'An unexpected error occurred. Please try again.' }); + return false; + } finally { + setIsLoading(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + const success = await submitToDatabase(); + if (success) { + handleClose(); + } + }; + + const handleClose = () => { + setFormData({ + name: '', + description: '', + total_duration: 1, + is_splittable: true, + min_split_duration_hours: 0.5, + max_split_duration_hours: 2, + time_reference: 'working_time', + start_date: '', + end_date: '', + }); + setErrors({}); + onClose?.(); + }; + + const workingHoursOptions = [ + { + value: 'working_time', + label: 'Working Hours', + icon: '💼', + description: 'Schedule during standard work hours', + }, + { + value: 'personal_time', + label: 'Personal Time', + icon: '⚙️', + description: 'Schedule at any time of day', + }, + ]; + + return ( + + + + + + Create New Task + + + Schedule a new task with your preferred settings and constraints. + + + +
+ {/* Basic Information */} +
+
+ + updateFormData('name', e.target.value)} + className={errors.name ? 'border-destructive' : ''} + /> + {errors.name && ( +

{errors.name}

+ )} +
+ +
+ +