Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ interface CalendarEventChipProps {
* details modal carries the state. The pill is the grid's real `<button>` (its
* parent cells are plain clickable `<div>`s), so tasks are the tab-reachable
* elements; clicks stop propagating so the cell underneath doesn't also open
* the create modal.
* the create modal. A paused task (`task.disabled`) renders dimmed — the one
* status the pill signals visually, since a paused task can sit on the calendar
* indefinitely without running.
*/
export function CalendarEventChip({
event,
Expand All @@ -49,6 +51,7 @@ export function CalendarEventChip({
chipContentGap,
chipPrimaryFillTokens,
'hover-hover:bg-[var(--text-body)] dark:hover-hover:bg-[var(--text-secondary)]',
event.task.disabled && 'opacity-45',
className
)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Duplicate as DuplicateIcon, Pencil, Trash } from '@/components/emcn/icons'
import { Duplicate as DuplicateIcon, Pause, Pencil, Play, Trash } from '@/components/emcn/icons'
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'

interface TaskContextMenuProps {
Expand All @@ -19,13 +19,18 @@ interface TaskContextMenuProps {
onEdit: () => void
/** Opens a new-task modal pre-filled from this task. */
onDuplicate: () => void
/** Pauses an active recurring task — suspends its future runs. */
onPause: () => void
/** Resumes a paused recurring task. */
onResume: () => void
onDelete: () => void
}

/**
* Right-click menu for a calendar task pill. Upcoming (`pending`) tasks can be
* edited or deleted; any task can be duplicated into a new one. Finished tasks
* open their read-only record on click, so the menu only offers Duplicate.
* edited or deleted, and recurring ones paused or resumed; any task can be
* duplicated into a new one. Finished tasks open their read-only record on
* click, so the menu only offers Duplicate.
*/
export function TaskContextMenu({
isOpen,
Expand All @@ -34,9 +39,13 @@ export function TaskContextMenu({
task,
onEdit,
onDuplicate,
onPause,
onResume,
onDelete,
}: TaskContextMenuProps) {
const isUpcoming = task?.status === 'pending'
/** Pause/Resume applies to recurring tasks only — one-time tasks carry no cadence. */
const canPauseResume = isUpcoming && task?.recurring === true

return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
Expand Down Expand Up @@ -67,6 +76,18 @@ export function TaskContextMenu({
<Pencil />
Edit
</DropdownMenuItem>
{canPauseResume &&
(task?.disabled ? (
<DropdownMenuItem onSelect={onResume}>
<Play />
Resume
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={onPause}>
<Pause />
Pause
</DropdownMenuItem>
))}
<DropdownMenuItem onSelect={onDuplicate}>
<DuplicateIcon />
Duplicate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,12 @@ interface TaskModalProps {
edit?: TaskEditSeed | null
/** Pre-fill for a create (duplicate): opens in create mode with every field copied. */
prefill?: TaskPrefill | null
/** Receives the captured draft on submit (create and save alike). */
onSubmit: (draft: TaskDraft) => void
/**
* Receives the captured draft on submit (create and save alike). May return a
* promise — the modal awaits it, keeping itself open until the task persists
* and closing only on success, so a failed save never silently discards the draft.
*/
onSubmit: (draft: TaskDraft) => void | Promise<void>
/** Asks the parent to start the delete flow (which handles the recurring this/all choice). */
onRequestDelete?: () => void
}
Expand All @@ -127,25 +131,53 @@ export function TaskModal({
onSubmit,
onRequestDelete,
}: TaskModalProps) {
const [submitting, setSubmitting] = useState(false)

/**
* While a save is in flight, swallow every dismiss path — Cancel, header X,
* Escape, and overlay click all route through this one handler — so an
* in-progress create/edit can't be abandoned and lose its draft. `submitting`
* lives here (not in the unmounted-on-close content) so this guard can see it.
*
* The programmatic close on a *successful* submit is intentionally NOT blocked:
* `handleSubmit` runs in the pre-submit render where `submitting` was still
* false, so its `close()` resolves to that render's handler and passes through,
* while user dismisses fire from the current (submitting) render and are caught
* here. Keep `submitting` as render state — moving it to a ref or memoizing this
* handler with `submitting` in deps would make the success-close start blocking.
*/
const handleOpenChange = (next: boolean) => {
if (!next && submitting) return
onOpenChange(next)
}

return (
<ChipModal
open={open}
onOpenChange={onOpenChange}
onOpenChange={handleOpenChange}
size='lg'
srTitle={edit ? 'Edit scheduled task' : 'New scheduled task'}
>
<TaskModalContent
onOpenChange={onOpenChange}
onOpenChange={handleOpenChange}
slot={slot}
edit={edit}
prefill={prefill}
onSubmit={onSubmit}
onRequestDelete={onRequestDelete}
submitting={submitting}
setSubmitting={setSubmitting}
/>
</ChipModal>
)
}

interface TaskModalContentProps extends Omit<TaskModalProps, 'open'> {
/** Whether a save is in flight — owned by {@link TaskModal} so the dismiss guard can read it. */
submitting: boolean
setSubmitting: (submitting: boolean) => void
}

/**
* Inner content, mounted only while the dialog is open (the Radix portal
* unmounts closed content). Holding the editor here keeps its mention-data
Expand All @@ -158,7 +190,9 @@ function TaskModalContent({
prefill,
onSubmit,
onRequestDelete,
}: Omit<TaskModalProps, 'open'>) {
submitting,
setSubmitting,
}: TaskModalContentProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const source = edit ?? prefill
const accountTimezone = useTimezone()
Expand Down Expand Up @@ -190,6 +224,14 @@ function TaskModalContent({
() => source?.recurrence ?? DEFAULT_RECURRENCE
)
const launchEditedRef = useRef(false)
/**
* Synchronous mirror of `submitting` that gates {@link handleSubmit}. The
* `submitting` state only reflects after a re-render, so two invocations in the
* same tick (Enter racing the click) could both pass a state-based guard; the
* ref flips immediately, so the second is rejected before it can fire a second
* mutation.
*/
const submittingRef = useRef(false)

/**
* Re-seed a blank create's default launch when the effective zone resolves
Expand Down Expand Up @@ -223,22 +265,52 @@ function TaskModalContent({

const promptText = editor.value.trim()

const handleSubmit = () => {
if (!promptText || isPastLaunch) return
onSubmit({
prompt: editor.getPlainValue().trim(),
contexts: editor.contexts.length > 0 ? editor.contexts : undefined,
launchDate,
launchTime,
timezone,
recurrence,
})
close()
/**
* Submits the draft and waits for it to persist. The synchronous
* {@link submittingRef} guard blocks a double-submit (Enter racing the click).
* The modal closes only when the save resolves; a rejection leaves it open so
* the draft survives — the mutation hook already surfaces the error via toast,
* so it is swallowed here rather than duplicated. Both the ref and the
* `submitting` state are always cleared, so the button can never stick disabled
* while the modal stays open.
*/
const handleSubmit = async () => {
if (!promptText || isPastLaunch || submittingRef.current) return
submittingRef.current = true
setSubmitting(true)
const persisted = await Promise.resolve(
onSubmit({
prompt: editor.getPlainValue().trim(),
contexts: editor.contexts.length > 0 ? editor.contexts : undefined,
launchDate,
launchTime,
timezone,
recurrence,
})
)
.then(() => true)
.catch(() => false)
submittingRef.current = false
setSubmitting(false)
if (persisted) close()
Comment thread
waleedlatif1 marked this conversation as resolved.
}
Comment thread
waleedlatif1 marked this conversation as resolved.

/**
* Footer secondary actions. Delete is disabled while `submitting` because it
* bypasses the dismiss guard — it closes the modal via `closeTask`, not the
* guarded `onOpenChange` — so without the lock an in-flight edit and a delete
* could run against the same task at once.
*/
const secondaryActions: ChipModalFooterSlotAction[] = [
...(edit && onRequestDelete
? [{ label: 'Delete', variant: 'destructive' as const, onClick: onRequestDelete }]
? [
{
label: 'Delete',
variant: 'destructive' as const,
onClick: onRequestDelete,
disabled: submitting,
},
]
: []),
{
custom: (
Expand Down Expand Up @@ -268,11 +340,12 @@ function TaskModalContent({
</ChipModalPromptBody>
<ChipModalFooter
onCancel={close}
cancelDisabled={submitting}
secondaryActions={secondaryActions}
primaryAction={{
label: edit ? 'Save' : 'Schedule',
label: submitting ? (edit ? 'Saving...' : 'Scheduling...') : edit ? 'Save' : 'Schedule',
onClick: handleSubmit,
disabled: !promptText || isPastLaunch,
disabled: !promptText || isPastLaunch || submitting,
Comment thread
waleedlatif1 marked this conversation as resolved.
disabledTooltip: isPastLaunch ? PAST_LAUNCH_MESSAGE : undefined,
}}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import {
import {
useCreateSchedule,
useDeleteSchedule,
useDisableSchedule,
useExcludeOccurrence,
useResumeSchedule,
useUpdateSchedule,
useWorkspaceSchedules,
} from '@/hooks/queries/schedules'
Expand Down Expand Up @@ -91,12 +93,18 @@ export interface UseScheduledTasksReturn {
closeTask: () => void
/** Recovers the modal's edit seed (recurrence, launch) from a task's schedule. */
editSeedFor: (task: ScheduledTask) => TaskEditSeed | null
createTask: (draft: TaskDraft) => void
updateTask: (scheduleId: string, draft: TaskDraft) => void
/** Resolves once the create persists; rejects on failure so the modal stays open. */
createTask: (draft: TaskDraft) => Promise<void>
/** Resolves once the edit persists; rejects on failure so the modal stays open. */
updateTask: (scheduleId: string, draft: TaskDraft) => Promise<void>
/** Deletes the whole task (one-time or the entire recurring series). */
deleteTask: (scheduleId: string) => void
/** Deletes a single occurrence of a recurring task. */
deleteOccurrence: (scheduleId: string, occurrence: Date) => void
/** Pauses a recurring task — suspends future runs until resumed. */
pauseTask: (scheduleId: string) => void
/** Resumes a paused recurring task, recomputing its next run from the cron. */
resumeTask: (scheduleId: string) => void
}

/**
Expand All @@ -115,6 +123,8 @@ export function useScheduledTasks({
const updateSchedule = useUpdateSchedule()
const deleteSchedule = useDeleteSchedule()
const excludeOccurrence = useExcludeOccurrence()
const disableSchedule = useDisableSchedule()
const resumeSchedule = useResumeSchedule()

const [selectedTask, setSelectedTask] = useState<ScheduledTask | null>(null)

Expand Down Expand Up @@ -157,15 +167,16 @@ export function useScheduledTasks({
)

const createTask = useCallback(
(draft: TaskDraft) => createSchedule.mutate(draftToCreateBody(draft, workspaceId)),
async (draft: TaskDraft) => {
await createSchedule.mutateAsync(draftToCreateBody(draft, workspaceId))
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[workspaceId]
)

const updateTask = useCallback(
(scheduleId: string, draft: TaskDraft) => {
updateSchedule.mutate({ scheduleId, workspaceId, ...draftToUpdateBody(draft) })
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
async (scheduleId: string, draft: TaskDraft) => {
await updateSchedule.mutateAsync({ scheduleId, workspaceId, ...draftToUpdateBody(draft) })
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[workspaceId]
Expand All @@ -189,6 +200,24 @@ export function useScheduledTasks({
[workspaceId]
)

const pauseTask = useCallback(
(scheduleId: string) => {
disableSchedule.mutate({ scheduleId, workspaceId })
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[workspaceId]
)

const resumeTask = useCallback(
(scheduleId: string) => {
resumeSchedule.mutate({ scheduleId, workspaceId })
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[workspaceId]
)

return {
isLoading,
eventsByDay,
Expand All @@ -200,5 +229,7 @@ export function useScheduledTasks({
updateTask,
deleteTask,
deleteOccurrence,
pauseTask,
resumeTask,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ export function ScheduledTasks() {
if (contextTask) handleOpenTask(contextTask)
}, [contextTask, handleOpenTask])

const handlePauseContextTask = useCallback(() => {
Comment thread
waleedlatif1 marked this conversation as resolved.
if (contextTask) tasks.pauseTask(contextTask.scheduleId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contextTask])

const handleResumeContextTask = useCallback(() => {
if (contextTask) tasks.resumeTask(contextTask.scheduleId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contextTask])

const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
Expand Down Expand Up @@ -175,6 +185,8 @@ export function ScheduledTasks() {
task={contextTask}
onEdit={openContextTask}
onDuplicate={handleDuplicate}
onPause={handlePauseContextTask}
onResume={handleResumeContextTask}
Comment thread
waleedlatif1 marked this conversation as resolved.
onDelete={() => setDeletingTask(contextTask)}
/>

Expand Down Expand Up @@ -205,7 +217,7 @@ export function ScheduledTasks() {
}}
edit={editSeed}
onSubmit={(draft) => {
if (editTask) tasks.updateTask(editTask.scheduleId, draft)
if (editTask) return tasks.updateTask(editTask.scheduleId, draft)
}}
onRequestDelete={() => {
setDeletingTask(editTask)
Expand Down
Loading
Loading