chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
import { chipContentGap, chipPrimaryFillTokens, cn } from '@sim/emcn'
|
||||
import { format } from 'date-fns'
|
||||
import type {
|
||||
CalendarEvent,
|
||||
ScheduledTask,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
|
||||
|
||||
interface CalendarEventChipProps {
|
||||
event: CalendarEvent
|
||||
onSelect: (task: ScheduledTask) => void
|
||||
/** Right-click — open the task's context menu at the cursor. */
|
||||
onContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
|
||||
/** Layout/sizing only (`w-full`, `min-w-0 flex-1`); chrome lives here. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact task pill rendered inside a month day cell or a time-grid slot — the
|
||||
* one leaf shared by both grids. Every task renders identically regardless of
|
||||
* status — plaintext start time + title, no icons or status colors; the
|
||||
* 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. 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,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
className,
|
||||
}: CalendarEventChipProps) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect(event.task)
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onContextMenu(event.task, e)
|
||||
}}
|
||||
className={cn(
|
||||
'flex min-w-0 cursor-pointer items-center truncate rounded-md px-1.5 py-0.5 text-left text-caption outline-none transition-colors',
|
||||
chipContentGap,
|
||||
chipPrimaryFillTokens,
|
||||
'hover-hover:bg-[var(--text-body)] dark:hover-hover:bg-[var(--text-secondary)]',
|
||||
event.task.disabled && 'opacity-45',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className='flex-shrink-0'>{format(event.start, 'h:mm a')}</span>
|
||||
<span className='min-w-0 truncate'>{event.title}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { CalendarEventChip } from './calendar-event-chip'
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Check,
|
||||
Chip,
|
||||
ChipDatePicker,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@sim/emcn'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import type { CalendarScope } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
|
||||
|
||||
const SCOPE_OPTIONS: { value: CalendarScope; label: string }[] = [
|
||||
{ value: 'day', label: 'Day' },
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
]
|
||||
|
||||
interface CalendarToolbarProps {
|
||||
scope: CalendarScope
|
||||
anchor: Date
|
||||
label: string
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onToday: () => void
|
||||
onSelectDate: (date: Date) => void
|
||||
onScopeChange: (scope: CalendarScope) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar ribbon: a "Today" jump and the period-label date picker on the left;
|
||||
* the prev/next chevrons and the scope picker on the right. The controls are
|
||||
* bare chips — the period label is a ghost `ChipDatePicker` that jumps the view
|
||||
* to any picked date — and the scope picker is a `DropdownMenu`, matching the
|
||||
* Filter/Sort menus on the resource options bar.
|
||||
*/
|
||||
export function CalendarToolbar({
|
||||
scope,
|
||||
anchor,
|
||||
label,
|
||||
onPrev,
|
||||
onNext,
|
||||
onToday,
|
||||
onSelectDate,
|
||||
onScopeChange,
|
||||
}: CalendarToolbarProps) {
|
||||
const scopeLabel = SCOPE_OPTIONS.find((option) => option.value === scope)?.label ?? 'Week'
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between border-[var(--border)] border-b px-4 py-2.5'>
|
||||
<div className='flex items-center'>
|
||||
<Chip onClick={onToday}>Today</Chip>
|
||||
<ChipDatePicker
|
||||
variant='ghost'
|
||||
label={label}
|
||||
value={format(anchor, 'yyyy-MM-dd')}
|
||||
onChange={(value) => onSelectDate(parseISO(value))}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<Chip leftIcon={ChevronLeft} aria-label='Previous' onClick={onPrev} />
|
||||
<Chip leftIcon={ChevronRight} aria-label='Next' onClick={onNext} />
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Chip>{scopeLabel}</Chip>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' alignOffset={6}>
|
||||
{SCOPE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.value} onSelect={() => onScopeChange(option.value)}>
|
||||
{option.label}
|
||||
{option.value === scope && (
|
||||
<Check className='ml-auto size-[12px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { CalendarToolbar } from './calendar-toolbar'
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export { CalendarEventChip } from './calendar-event-chip'
|
||||
export { CalendarToolbar } from './calendar-toolbar'
|
||||
export { MonthGrid } from './month-grid'
|
||||
export { TimeGrid } from './time-grid'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { MonthGrid } from './month-grid'
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
import { chipPrimaryFillTokens, cn } from '@sim/emcn'
|
||||
import { format } from 'date-fns'
|
||||
import { CalendarEventChip } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip'
|
||||
import {
|
||||
type CalendarDayCell,
|
||||
type MonthGrid as MonthGridData,
|
||||
WEEKDAY_LABELS,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
|
||||
import {
|
||||
type CalendarEvent,
|
||||
dayKey,
|
||||
type ScheduledTask,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
|
||||
|
||||
/**
|
||||
* Lines of task content a month day cell shows before collapsing the rest
|
||||
* behind a "N more" overflow line. Capping at a fixed line count keeps every
|
||||
* cell's height contribution bounded no matter how many tasks pile onto a day.
|
||||
*/
|
||||
const MAX_DAY_EVENT_LINES = 3
|
||||
|
||||
interface MonthGridProps {
|
||||
grid: MonthGridData
|
||||
onSelectDay: (date: Date) => void
|
||||
onSelectTask: (task: ScheduledTask) => void
|
||||
/** A task pill was right-clicked — open its context menu at the cursor. */
|
||||
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
|
||||
/** Drill into the day scope, where overflowing tasks have room to render. */
|
||||
onShowDay: (date: Date) => void
|
||||
eventsByDay?: Map<string, CalendarEvent[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* One day in the month grid. Clicking empty space opens the create modal; the
|
||||
* cell is a plain clickable `<div>` so the task pills inside can be real
|
||||
* `<button>`s without nesting interactive elements. Tasks stack vertically up
|
||||
* to {@link MAX_DAY_EVENT_LINES} lines — beyond that the last line becomes a
|
||||
* plaintext "N more" that jumps to the day view.
|
||||
*
|
||||
* The day number carries a 1px optical inset (`ml-px`) on top of the shared
|
||||
* `p-1.5` cell padding: digit glyphs and the cap-height weekday labels above
|
||||
* carry different side bearings, so box-flush alignment reads visibly off at
|
||||
* this size. 1px is the tuned value — 0 reads short of the label, 2px reads
|
||||
* past it.
|
||||
*/
|
||||
function DayCell({
|
||||
cell,
|
||||
events,
|
||||
colIndex,
|
||||
onSelect,
|
||||
onSelectTask,
|
||||
onTaskContextMenu,
|
||||
onShowDay,
|
||||
}: {
|
||||
cell: CalendarDayCell
|
||||
events: CalendarEvent[]
|
||||
colIndex: number
|
||||
onSelect: (date: Date) => void
|
||||
onSelectTask: (task: ScheduledTask) => void
|
||||
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
|
||||
onShowDay: (date: Date) => void
|
||||
}) {
|
||||
const visible =
|
||||
events.length > MAX_DAY_EVENT_LINES ? events.slice(0, MAX_DAY_EVENT_LINES - 1) : events
|
||||
const hiddenCount = events.length - visible.length
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(cell.date)}
|
||||
className={cn(
|
||||
'flex min-h-0 min-w-0 cursor-pointer flex-col items-start gap-1 overflow-hidden border-[var(--border)] border-r border-b p-1.5 transition-colors hover-hover:bg-[var(--surface-active)]',
|
||||
colIndex === 0 && 'pl-6',
|
||||
colIndex === 6 && 'pr-6'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-px flex h-[26px] flex-shrink-0 items-center rounded-lg text-caption',
|
||||
cell.isToday
|
||||
? cn('w-[26px] justify-center', chipPrimaryFillTokens)
|
||||
: cell.isCurrentMonth
|
||||
? 'text-[var(--text-body)]'
|
||||
: 'text-[var(--text-muted)]'
|
||||
)}
|
||||
>
|
||||
{format(cell.date, 'd')}
|
||||
</span>
|
||||
<div className='flex w-full min-w-0 flex-col gap-0.5'>
|
||||
{visible.map((event) => (
|
||||
<CalendarEventChip
|
||||
key={event.id}
|
||||
event={event}
|
||||
onSelect={onSelectTask}
|
||||
onContextMenu={onTaskContextMenu}
|
||||
/>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onShowDay(cell.date)
|
||||
}}
|
||||
className='cursor-pointer px-1.5 py-0.5 text-left text-[var(--text-muted)] text-micro outline-none transition-colors hover-hover:text-[var(--text-body)]'
|
||||
>
|
||||
{hiddenCount} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Month scope: a sticky weekday header over a 7-column grid of day cells that
|
||||
* fills the body height. All seven tracks are equal, so the border-to-border
|
||||
* column rhythm is even; the edge cells span clear to the panel edges (the page
|
||||
* gutter stays hoverable and clickable) and inset their own content via
|
||||
* `pl-6`/`pr-6`. Events flow in via `eventsByDay` — the single injection point
|
||||
* the container fills.
|
||||
*/
|
||||
export function MonthGrid({
|
||||
grid,
|
||||
onSelectDay,
|
||||
onSelectTask,
|
||||
onTaskContextMenu,
|
||||
onShowDay,
|
||||
eventsByDay,
|
||||
}: MonthGridProps) {
|
||||
return (
|
||||
<div className='flex h-full min-h-0 flex-col'>
|
||||
<div className='sticky top-0 z-10 grid grid-cols-7 border-[var(--border)] border-b bg-[var(--bg)]'>
|
||||
{WEEKDAY_LABELS.map((label, index) => (
|
||||
<div
|
||||
key={label}
|
||||
className={cn(
|
||||
'p-1.5 text-[var(--text-muted)] text-caption',
|
||||
index === 0 && 'pl-6',
|
||||
index === WEEKDAY_LABELS.length - 1 && 'pr-6'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className='grid min-h-0 flex-1 grid-cols-7'
|
||||
style={{ gridTemplateRows: `repeat(${grid.weeks.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{grid.weeks.map((week) =>
|
||||
week.map((cell, colIndex) => (
|
||||
<DayCell
|
||||
key={cell.date.toISOString()}
|
||||
cell={cell}
|
||||
colIndex={colIndex}
|
||||
events={eventsByDay?.get(dayKey(cell.date)) ?? []}
|
||||
onSelect={onSelectDay}
|
||||
onSelectTask={onSelectTask}
|
||||
onTaskContextMenu={onTaskContextMenu}
|
||||
onShowDay={onShowDay}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { TimeGrid } from './time-grid'
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { chipPrimaryFillTokens, cn } from '@sim/emcn'
|
||||
import { format } from 'date-fns'
|
||||
import { zonedClockDate } from '@/lib/core/utils/timezone'
|
||||
import { CalendarEventChip } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip'
|
||||
import {
|
||||
type CalendarDayCell,
|
||||
EVENT_CHIP_HEIGHT,
|
||||
formatHourLabel,
|
||||
formatSlotTime,
|
||||
layoutColumn,
|
||||
TIME_SLOT_HEIGHT,
|
||||
timeToOffset,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
|
||||
import {
|
||||
type CalendarEvent,
|
||||
dayKey,
|
||||
type ScheduledTask,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
|
||||
|
||||
const GUTTER_WIDTH = 56
|
||||
|
||||
/** Re-render cadence for the current-time indicator. */
|
||||
const TICK_MS = 60_000
|
||||
|
||||
interface TimeGridProps {
|
||||
/** One column per day: 7 for week scope, 1 for day scope. */
|
||||
days: CalendarDayCell[]
|
||||
hours: number[]
|
||||
/** The viewer's effective timezone — positions the now-line. */
|
||||
timezone: string
|
||||
onSelectSlot: (date: Date, time: string) => void
|
||||
onSelectTask: (task: ScheduledTask) => void
|
||||
/** A task pill was right-clicked — open its context menu at the cursor. */
|
||||
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
|
||||
eventsByDay?: Map<string, CalendarEvent[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Live now-line drawn over today's column — a chip-primary dot at the left edge
|
||||
* and a hairline across the column, positioned by {@link timeToOffset}. Renders
|
||||
* nothing until mounted (keeps SSR output stable, avoiding a hydration mismatch
|
||||
* on the time-dependent offset), then ticks once a minute so the line advances.
|
||||
* Positioned in `timezone` so it tracks the same zone the day columns render in.
|
||||
* The parent column is `relative`; this is `absolute`.
|
||||
*/
|
||||
function CurrentTimeIndicator({ timezone }: { timezone: string }) {
|
||||
const [now, setNow] = useState<Date | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setNow(new Date())
|
||||
const interval = setInterval(() => setNow(new Date()), TICK_MS)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (!now) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ top: timeToOffset(zonedClockDate(now, timezone)) }}
|
||||
className='pointer-events-none absolute inset-x-0 z-20'
|
||||
>
|
||||
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-0 left-0 size-[10px] rounded-full bg-[var(--text-primary)] dark:bg-white' />
|
||||
<div className='-translate-y-1/2 absolute inset-x-0 top-0 h-[2px] bg-[var(--text-primary)] dark:bg-white' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One hour cell in a day column: a click target that opens the create modal
|
||||
* seeded to this hour, plus the hour's gridlines. Tasks are not rendered here —
|
||||
* they live in the day's {@link DayEvents} overlay so each sits at its exact
|
||||
* minute rather than snapping to the top of the hour.
|
||||
*/
|
||||
function HourCell({
|
||||
date,
|
||||
hour,
|
||||
isLastColumn,
|
||||
onSelect,
|
||||
}: {
|
||||
date: Date
|
||||
hour: number
|
||||
isLastColumn: boolean
|
||||
onSelect: (date: Date, time: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(date, formatSlotTime(hour))}
|
||||
style={{ height: TIME_SLOT_HEIGHT }}
|
||||
className={cn(
|
||||
'cursor-pointer border-[var(--border)] border-r border-b transition-colors hover-hover:bg-[var(--surface-active)]',
|
||||
isLastColumn && 'pr-6'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A day column's task pills, each absolutely positioned at its exact start time
|
||||
* via {@link timeToOffset}. The layer is non-interactive so empty space falls
|
||||
* through to the hour cells beneath (click-to-create); the pills re-enable
|
||||
* pointer events. The layer clips to the day's bounds so a late-night pill never
|
||||
* spills past the final hour row. Coincident tasks overlap by design.
|
||||
*/
|
||||
function DayEvents({
|
||||
events,
|
||||
isLastColumn,
|
||||
onSelectTask,
|
||||
onTaskContextMenu,
|
||||
}: {
|
||||
events: CalendarEvent[]
|
||||
isLastColumn: boolean
|
||||
onSelectTask: (task: ScheduledTask) => void
|
||||
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
|
||||
}) {
|
||||
const placed = layoutColumn(events, EVENT_CHIP_HEIGHT)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 left-0.5 z-10 overflow-hidden',
|
||||
isLastColumn ? 'right-6' : 'right-0.5'
|
||||
)}
|
||||
>
|
||||
{placed.map(({ item: event, topPx, lane, lanes }) => (
|
||||
<div
|
||||
key={event.id}
|
||||
style={{
|
||||
top: topPx,
|
||||
height: EVENT_CHIP_HEIGHT,
|
||||
left: `${(lane / lanes) * 100}%`,
|
||||
width: `${(1 / lanes) * 100}%`,
|
||||
}}
|
||||
className='pointer-events-auto absolute pr-0.5'
|
||||
>
|
||||
<CalendarEventChip
|
||||
event={event}
|
||||
onSelect={onSelectTask}
|
||||
onContextMenu={onTaskContextMenu}
|
||||
className='h-full w-full'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared time-based grid for the week (7 columns) and day (1 column) scopes: a
|
||||
* sticky day header, a fixed hour gutter, and a stack of hour slots per day.
|
||||
* Column widths come from a CSS grid template shared by the header and body so
|
||||
* they stay aligned. The sticky header paints chrome on the day cells only —
|
||||
* its gutter spacer is transparent and border-free, so the hour labels scroll
|
||||
* clear to the top of the viewport. Today's column is `relative` and hosts the
|
||||
* {@link CurrentTimeIndicator}. Events flow in via `eventsByDay` — the single
|
||||
* injection point the container fills.
|
||||
*/
|
||||
export function TimeGrid({
|
||||
days,
|
||||
hours,
|
||||
timezone,
|
||||
onSelectSlot,
|
||||
onSelectTask,
|
||||
onTaskContextMenu,
|
||||
eventsByDay,
|
||||
}: TimeGridProps) {
|
||||
const columnsStyle = {
|
||||
gridTemplateColumns: `${GUTTER_WIDTH}px repeat(${days.length}, minmax(0, 1fr))`,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-0 flex-col'>
|
||||
<div data-time-grid-header style={columnsStyle} className='sticky top-0 z-20 grid'>
|
||||
<div className='border-[var(--border)] border-r' />
|
||||
{days.map((day, dayIndex) => (
|
||||
<div
|
||||
key={day.date.toISOString()}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 border-[var(--border)] border-r border-b bg-[var(--bg)] py-2',
|
||||
dayIndex === days.length - 1 && 'pr-6'
|
||||
)}
|
||||
>
|
||||
<span className='text-[var(--text-muted)] text-caption'>{format(day.date, 'EEE')}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-[26px] items-center justify-center rounded-lg text-caption',
|
||||
day.isToday ? chipPrimaryFillTokens : 'text-[var(--text-body)]'
|
||||
)}
|
||||
>
|
||||
{format(day.date, 'd')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={columnsStyle} className='grid'>
|
||||
<div className='flex flex-col'>
|
||||
{hours.map((hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
style={{ height: TIME_SLOT_HEIGHT }}
|
||||
className='relative border-[var(--border)] border-r'
|
||||
>
|
||||
<span className='-translate-y-1/2 absolute top-0 right-1.5 text-[var(--text-muted)] text-micro'>
|
||||
{formatHourLabel(hour)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{days.map((day, dayIndex) => (
|
||||
<div key={day.date.toISOString()} className='relative flex flex-col'>
|
||||
{day.isToday && <CurrentTimeIndicator timezone={timezone} />}
|
||||
{hours.map((hour) => (
|
||||
<HourCell
|
||||
key={hour}
|
||||
date={day.date}
|
||||
hour={hour}
|
||||
isLastColumn={dayIndex === days.length - 1}
|
||||
onSelect={onSelectSlot}
|
||||
/>
|
||||
))}
|
||||
<DayEvents
|
||||
events={eventsByDay?.get(dayKey(day.date)) ?? []}
|
||||
isLastColumn={dayIndex === days.length - 1}
|
||||
onSelectTask={onSelectTask}
|
||||
onTaskContextMenu={onTaskContextMenu}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ScheduleCalendar } from './schedule-calendar'
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { zonedClockDate } from '@/lib/core/utils/timezone'
|
||||
import {
|
||||
CalendarToolbar,
|
||||
MonthGrid,
|
||||
TimeGrid,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components'
|
||||
import {
|
||||
buildCalendarGrid,
|
||||
type CalendarScope,
|
||||
formatScopeLabel,
|
||||
timeToOffset,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
|
||||
import type {
|
||||
CalendarEvent,
|
||||
ScheduledTask,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
|
||||
|
||||
interface ScheduleCalendarProps {
|
||||
scope: CalendarScope
|
||||
anchor: Date
|
||||
today: Date
|
||||
/** The viewer's effective timezone — positions the now-line and centering. */
|
||||
timezone: string
|
||||
onScopeChange: (scope: CalendarScope) => void
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onToday: () => void
|
||||
onSelectDate: (date: Date) => void
|
||||
onSelectSlot: (date: Date, time?: string) => void
|
||||
/** A task pill was clicked — open its details modal. */
|
||||
onSelectTask: (task: ScheduledTask) => void
|
||||
/** A task pill was right-clicked — open its context menu at the cursor. */
|
||||
onTaskContextMenu: (task: ScheduledTask, e: React.MouseEvent) => void
|
||||
/** A month cell's overflow line was clicked — jump to that day's view. */
|
||||
onShowDay: (date: Date) => void
|
||||
/** Day-bucketed events feeding both the month grid and the time grid. */
|
||||
eventsByDay?: Map<string, CalendarEvent[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar body for the scheduled-tasks page. Owns the scroll region and view
|
||||
* dispatch: it renders the toolbar, derives the grid from the page's
|
||||
* `useCalendar` state, and switches between the month grid and the shared time
|
||||
* grid on the grid discriminant.
|
||||
*
|
||||
* Scroll behavior: entering week/day scope, and "Today" presses (signaled via an
|
||||
* internal `scrollSignal`), center the current time in the viewport; month scope
|
||||
* resets to the top. Plain prev/next navigation never re-centers. Today presses
|
||||
* scroll smoothly as an orientation cue; mount and scope switches position
|
||||
* instantly (animating initial placement would read as a glitch). Centering is
|
||||
* computed from the time-grid header height plus {@link timeToOffset} rather than
|
||||
* the now-line element, so it works even on first paint before the line mounts.
|
||||
*
|
||||
* Event injection is the single integration point — `eventsByDay` is threaded
|
||||
* straight into both grids, which forward it to their cells.
|
||||
*/
|
||||
export function ScheduleCalendar({
|
||||
scope,
|
||||
anchor,
|
||||
today,
|
||||
timezone,
|
||||
onScopeChange,
|
||||
onPrev,
|
||||
onNext,
|
||||
onToday,
|
||||
onSelectDate,
|
||||
onSelectSlot,
|
||||
onSelectTask,
|
||||
onTaskContextMenu,
|
||||
onShowDay,
|
||||
eventsByDay,
|
||||
}: ScheduleCalendarProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const lastScrollSignalRef = useRef(0)
|
||||
const [scrollSignal, setScrollSignal] = useState(0)
|
||||
|
||||
const grid = useMemo(() => buildCalendarGrid(scope, anchor, today), [scope, anchor, today])
|
||||
const label = useMemo(() => formatScopeLabel(scope, anchor), [scope, anchor])
|
||||
|
||||
const handleToday = useCallback(() => {
|
||||
onToday()
|
||||
setScrollSignal((signal) => signal + 1)
|
||||
}, [onToday])
|
||||
|
||||
useEffect(() => {
|
||||
const region = scrollRef.current
|
||||
if (!region) return
|
||||
const behavior: ScrollBehavior =
|
||||
scrollSignal !== lastScrollSignalRef.current ? 'smooth' : 'auto'
|
||||
lastScrollSignalRef.current = scrollSignal
|
||||
if (scope === 'month') {
|
||||
region.scrollTo({ top: 0, behavior })
|
||||
return
|
||||
}
|
||||
const header = region.querySelector('[data-time-grid-header]')
|
||||
const headerHeight = header ? header.getBoundingClientRect().height : 0
|
||||
const target =
|
||||
headerHeight + timeToOffset(zonedClockDate(new Date(), timezone)) - region.clientHeight / 2
|
||||
region.scrollTo({ top: Math.max(0, target), behavior })
|
||||
}, [scope, scrollSignal, timezone])
|
||||
|
||||
return (
|
||||
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
|
||||
<CalendarToolbar
|
||||
scope={scope}
|
||||
anchor={anchor}
|
||||
label={label}
|
||||
onPrev={onPrev}
|
||||
onNext={onNext}
|
||||
onToday={handleToday}
|
||||
onSelectDate={onSelectDate}
|
||||
onScopeChange={onScopeChange}
|
||||
/>
|
||||
<div ref={scrollRef} className='min-h-0 flex-1 overflow-auto overscroll-none'>
|
||||
{grid.kind === 'month' ? (
|
||||
<MonthGrid
|
||||
grid={grid}
|
||||
onSelectDay={(date) => onSelectSlot(date)}
|
||||
onSelectTask={onSelectTask}
|
||||
onTaskContextMenu={onTaskContextMenu}
|
||||
onShowDay={onShowDay}
|
||||
eventsByDay={eventsByDay}
|
||||
/>
|
||||
) : (
|
||||
<TimeGrid
|
||||
days={grid.kind === 'week' ? grid.days : [grid.day]}
|
||||
hours={grid.hours}
|
||||
timezone={timezone}
|
||||
onSelectSlot={(date, time) => onSelectSlot(date, time)}
|
||||
onSelectTask={onSelectTask}
|
||||
onTaskContextMenu={onTaskContextMenu}
|
||||
eventsByDay={eventsByDay}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ScheduleListContextMenu } from './schedule-list-context-menu'
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn'
|
||||
import { Plus } from '@sim/emcn/icons'
|
||||
|
||||
interface ScheduleListContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
onCreateSchedule?: () => void
|
||||
disableCreate?: boolean
|
||||
}
|
||||
|
||||
export function ScheduleListContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
onCreateSchedule,
|
||||
disableCreate = false,
|
||||
}: ScheduleListContextMenuProps) {
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{onCreateSchedule && (
|
||||
<DropdownMenuItem disabled={disableCreate} onSelect={onCreateSchedule}>
|
||||
<Plus />
|
||||
New scheduled task
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { TaskContextMenu } from './task-context-menu'
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@sim/emcn'
|
||||
import { Duplicate as DuplicateIcon, Pause, Pencil, Play, Trash } from '@sim/emcn/icons'
|
||||
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
|
||||
|
||||
interface TaskContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
/** The right-clicked task; its status decides which actions render. */
|
||||
task: ScheduledTask | null
|
||||
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, 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,
|
||||
position,
|
||||
onClose,
|
||||
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}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{isUpcoming ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onEdit}>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{canPauseResume &&
|
||||
(task?.disabled ? (
|
||||
<DropdownMenuItem onSelect={onResume}>
|
||||
<Play />
|
||||
Resume
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={onPause}>
|
||||
<Pause />
|
||||
Pause
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuItem onSelect={onDuplicate}>
|
||||
<DuplicateIcon />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onDelete}>
|
||||
<Trash />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={onDuplicate}>
|
||||
<DuplicateIcon />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { TaskDeleteDialog } from './task-delete-dialog'
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ChipConfirmModal,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
} from '@sim/emcn'
|
||||
import { Calendar } from '@sim/emcn/icons'
|
||||
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
|
||||
|
||||
interface TaskDeleteDialogProps {
|
||||
/** The task targeted for deletion, or `null` to keep the dialog closed. */
|
||||
task: ScheduledTask | null
|
||||
onClose: () => void
|
||||
/** Delete just the targeted occurrence of a recurring task. */
|
||||
onDeleteOccurrence: (task: ScheduledTask) => void
|
||||
/** Delete a one-time task, or the entire recurring series. */
|
||||
onDeleteSeries: (task: ScheduledTask) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletion confirmation for a scheduled task. A one-time task takes a single
|
||||
* confirm; a recurring task offers the calendar-app choice between deleting
|
||||
* this occurrence and deleting the whole series.
|
||||
*/
|
||||
export function TaskDeleteDialog({
|
||||
task,
|
||||
onClose,
|
||||
onDeleteOccurrence,
|
||||
onDeleteSeries,
|
||||
}: TaskDeleteDialogProps) {
|
||||
if (task && !task.recurring) {
|
||||
return (
|
||||
<ChipConfirmModal
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose()
|
||||
}}
|
||||
title='Delete scheduled task'
|
||||
text='This task will be removed from the calendar and will not run.'
|
||||
confirm={{
|
||||
label: 'Delete',
|
||||
onClick: () => {
|
||||
onDeleteSeries(task)
|
||||
onClose()
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipModal
|
||||
open={task !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose()
|
||||
}}
|
||||
size='sm'
|
||||
srTitle='Delete recurring task'
|
||||
>
|
||||
{task && (
|
||||
<>
|
||||
<ChipModalHeader icon={Calendar} onClose={onClose}>
|
||||
Delete recurring task
|
||||
</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<p className='px-2 text-[var(--text-body)] text-sm'>
|
||||
This is a recurring task. Delete only this occurrence, or the entire series?
|
||||
</p>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={onClose}
|
||||
secondaryActions={[
|
||||
{
|
||||
label: 'This task',
|
||||
variant: 'destructive',
|
||||
onClick: () => {
|
||||
onDeleteOccurrence(task)
|
||||
onClose()
|
||||
},
|
||||
},
|
||||
]}
|
||||
primaryAction={{
|
||||
label: 'All tasks',
|
||||
variant: 'destructive',
|
||||
onClick: () => {
|
||||
onDeleteSeries(task)
|
||||
onClose()
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ChipModal>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { TaskDetailsModal } from './task-details-modal'
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
import {
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
chipFieldSurfaceClass,
|
||||
cn,
|
||||
} from '@sim/emcn'
|
||||
import { Calendar } from '@sim/emcn/icons'
|
||||
import { format } from 'date-fns'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
PromptEditor,
|
||||
usePromptEditor,
|
||||
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
|
||||
import type {
|
||||
ScheduledTask,
|
||||
ScheduledTaskStatus,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
|
||||
|
||||
/**
|
||||
* Plaintext copy per task state: the status label and the verb that titles the
|
||||
* run-time field — the tense carries the state ("Ran" is done, "Failed" errored).
|
||||
* No icons, no status colors, by design. Total over the status union for type
|
||||
* safety, though `pending` tasks open the edit `TaskModal` instead.
|
||||
*/
|
||||
const STATUS_COPY: Record<ScheduledTaskStatus, { label: string; timeTitle: string }> = {
|
||||
pending: { label: 'Pending', timeTitle: 'Runs' },
|
||||
error: { label: 'Error', timeTitle: 'Failed' },
|
||||
completed: { label: 'Completed', timeTitle: 'Ran' },
|
||||
}
|
||||
|
||||
interface TaskDetailsModalProps {
|
||||
/** The running or finished task to show. `null` keeps the modal closed. */
|
||||
task: ScheduledTask | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only record modal for tasks that are running or already finished —
|
||||
* pending tasks open the edit `TaskModal` instead. Three plaintext fields:
|
||||
* Status and the run time as copy fields, the prompt as a view-only chip editor.
|
||||
*/
|
||||
export function TaskDetailsModal({ task, onClose }: TaskDetailsModalProps) {
|
||||
return (
|
||||
<ChipModal
|
||||
open={task !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose()
|
||||
}}
|
||||
size='md'
|
||||
srTitle='Scheduled task'
|
||||
>
|
||||
{/* Key by the occurrence id so switching tasks while the modal stays open
|
||||
remounts the content — the editor seeds prompt + contexts on mount, so
|
||||
without a fresh mount it would keep showing the first task's prompt. */}
|
||||
{task && <TaskDetailsContent key={task.id} task={task} onClose={onClose} />}
|
||||
</ChipModal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner content, mounted only while a task is shown (the Radix portal unmounts
|
||||
* closed content). Holding the read-only editor here keeps its mention-data
|
||||
* queries from firing on page load and re-seeds from the task on each open.
|
||||
*/
|
||||
function TaskDetailsContent({ task, onClose }: { task: ScheduledTask; onClose: () => void }) {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
/**
|
||||
* Seed the stored resource mentions (files, tables, knowledge) as the editor's
|
||||
* initial contexts — these can't be recovered from the prompt text alone. The
|
||||
* mount chipify pass then merges integration `@`-mentions and `/`-skills on top
|
||||
* (they DO chipify from text), so the overlay renders the full set. Seeding is
|
||||
* deliberate over a post-mount `setContexts`, which would clobber the
|
||||
* auto-registered integration/skill contexts.
|
||||
*/
|
||||
const editor = usePromptEditor({
|
||||
workspaceId,
|
||||
initialValue: task.prompt,
|
||||
initialContexts: task.contexts,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChipModalHeader icon={Calendar} onClose={onClose}>
|
||||
Scheduled task
|
||||
</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField type='copy' title='Status' value={STATUS_COPY[task.status].label} />
|
||||
<ChipModalField
|
||||
type='copy'
|
||||
title={STATUS_COPY[task.status].timeTitle}
|
||||
value={format(task.runAt, "EEEE, MMMM d, yyyy 'at' h:mm a")}
|
||||
/>
|
||||
<ChipModalField type='custom' title='Prompt'>
|
||||
<div className={cn(chipFieldSurfaceClass, 'max-h-[200px] overflow-y-auto px-1 py-0.5')}>
|
||||
<PromptEditor editor={editor} readOnly aria-label='Prompt' />
|
||||
</div>
|
||||
</ChipModalField>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter onCancel={onClose} primaryAction={{ label: 'Done', onClick: onClose }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type TaskDraft, type TaskEditSeed, TaskModal, type TaskPrefill } from './task-modal'
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import {
|
||||
CalendarDayCell,
|
||||
ChipDatePicker,
|
||||
ChipModalField,
|
||||
ChipModalSeparator,
|
||||
Switch,
|
||||
} from '@sim/emcn'
|
||||
import { format } from 'date-fns'
|
||||
import type {
|
||||
MonthlyMode,
|
||||
Recurrence,
|
||||
RecurrenceFrequency,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
|
||||
|
||||
const WEEKDAY_PRESET = [1, 2, 3, 4, 5]
|
||||
/** Seed count when the user first chooses "ends after N runs". */
|
||||
const DEFAULT_END_AFTER_COUNT = 10
|
||||
/** Cadence a task falls back to when the user first flips on recurrence. */
|
||||
const DEFAULT_RECURRING_FREQUENCY = 'daily'
|
||||
|
||||
/** Sunday-first weekday order with single-letter labels and full names for a11y. */
|
||||
const WEEKDAYS = [
|
||||
{ value: 0, short: 'S', name: 'Sunday' },
|
||||
{ value: 1, short: 'M', name: 'Monday' },
|
||||
{ value: 2, short: 'T', name: 'Tuesday' },
|
||||
{ value: 3, short: 'W', name: 'Wednesday' },
|
||||
{ value: 4, short: 'T', name: 'Thursday' },
|
||||
{ value: 5, short: 'F', name: 'Friday' },
|
||||
{ value: 6, short: 'S', name: 'Saturday' },
|
||||
] as const
|
||||
|
||||
/** Ordinal words for the 1st–5th weekday-of-month, matching a calendar app's labels. */
|
||||
const ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth'] as const
|
||||
|
||||
/** The frequency presets the dropdown authors, keyed by a synthetic option value. */
|
||||
type FrequencyOption = 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'yearly' | 'custom'
|
||||
|
||||
function isWeekdayPreset(weekdays: number[]): boolean {
|
||||
return (
|
||||
weekdays.length === WEEKDAY_PRESET.length && WEEKDAY_PRESET.every((d) => weekdays.includes(d))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses a recurring recurrence into the single dropdown value that
|
||||
* represents it. `once` maps to the default cadence as an exhaustiveness
|
||||
* fallback: callers gate on `isRecurring`, so it never reaches here at runtime,
|
||||
* but the dropdown can't represent it — mapping it keeps the return type
|
||||
* `FrequencyOption` without a cast.
|
||||
*/
|
||||
function frequencyOptionFor(recurrence: Recurrence): FrequencyOption {
|
||||
if (recurrence.frequency === 'weekly')
|
||||
return isWeekdayPreset(recurrence.weekdays) ? 'weekdays' : 'weekly'
|
||||
if (recurrence.frequency === 'monthly') return 'monthly'
|
||||
if (recurrence.frequency === 'yearly') return 'yearly'
|
||||
if (recurrence.frequency === 'custom') return 'custom'
|
||||
if (recurrence.frequency === 'once') return DEFAULT_RECURRING_FREQUENCY
|
||||
return recurrence.frequency
|
||||
}
|
||||
|
||||
/**
|
||||
* The monthly sub-options, derived from the launch date the same way a calendar
|
||||
* app offers them: repeat on the day number, on the ordinal weekday of the
|
||||
* month (e.g. the third Tuesday), or on the last weekday of the month.
|
||||
*
|
||||
* The ordinal anchor is offered only for the 1st–4th occurrence: a 5th
|
||||
* occurrence is always the month's last weekday, so — like a calendar app — it
|
||||
* is folded into the "last weekday" option rather than offering a "fifth" that
|
||||
* would silently skip months without a 5th occurrence.
|
||||
*/
|
||||
function monthlyModeOptions(launch: Date): Array<{ value: MonthlyMode; label: string }> {
|
||||
const weekdayName = format(launch, 'EEEE')
|
||||
const ordinal = Math.ceil(launch.getDate() / 7)
|
||||
const options: Array<{ value: MonthlyMode; label: string }> = [
|
||||
{ value: 'day-of-month', label: `On day ${format(launch, 'd')}` },
|
||||
]
|
||||
if (ordinal <= 4)
|
||||
options.push({ value: 'nth-weekday', label: `On the ${ORDINALS[ordinal - 1]} ${weekdayName}` })
|
||||
options.push({ value: 'last-weekday', label: `On the last ${weekdayName}` })
|
||||
return options
|
||||
}
|
||||
|
||||
interface RecurrenceSectionProps {
|
||||
recurrence: Recurrence
|
||||
onChange: (recurrence: Recurrence) => void
|
||||
/** The launch day, so weekly/monthly labels name the weekday and day-of-month. */
|
||||
launchDate: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The repeat + end controls for a scheduled task, rendered as a body section
|
||||
* below the prompt: a "Recurring" {@link Switch} that toggles a one-time launch
|
||||
* into a repeat, and — once on — the frequency preset, its cadence detail (the
|
||||
* weekly day toggles or the monthly anchor), and how it ends (never, on a date,
|
||||
* or after N runs).
|
||||
*
|
||||
* Composed as a sibling between the prompt body and footer; it owns its own
|
||||
* leading separator and mirrors {@link ChipModalBody}'s spacing
|
||||
* (`gap-4 px-2 pt-4 pb-4.5`) so every {@link ChipModalField} lands at the same
|
||||
* effective `px-4` as the modal header/footer — no changes to the `ChipModal`
|
||||
* primitives.
|
||||
*/
|
||||
export function RecurrenceSection({ recurrence, onChange, launchDate }: RecurrenceSectionProps) {
|
||||
/**
|
||||
* The cadence to reinstate when recurrence is toggled back on. Toggling off
|
||||
* collapses `frequency` to `once`, dropping which preset was active, so the
|
||||
* last recurring cadence is cached here and restored — a paused "Weekly on
|
||||
* Mon" returns as weekly, not silently reset to daily. Written during render
|
||||
* (an idempotent cache), so it is current before the toggle handler reads it.
|
||||
*/
|
||||
const lastRecurringFrequency = useRef<RecurrenceFrequency>(DEFAULT_RECURRING_FREQUENCY)
|
||||
if (recurrence.frequency !== 'once') lastRecurringFrequency.current = recurrence.frequency
|
||||
|
||||
const launch = new Date(`${launchDate}T00:00`)
|
||||
const isRecurring = recurrence.frequency !== 'once'
|
||||
const selectedWeekdays = recurrence.weekdays.length > 0 ? recurrence.weekdays : [launch.getDay()]
|
||||
|
||||
const monthlyOptions = monthlyModeOptions(launch)
|
||||
const monthlyMode = recurrence.monthlyMode ?? 'day-of-month'
|
||||
// If the launch date drifted to a 5th occurrence, the nth anchor is no longer
|
||||
// offered; fall back to "last weekday", which is exactly what it compiles to.
|
||||
const monthlyValue = monthlyOptions.some((option) => option.value === monthlyMode)
|
||||
? monthlyMode
|
||||
: 'last-weekday'
|
||||
|
||||
const frequencyOptions = [
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'weekdays', label: 'Weekdays' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
{ value: 'yearly', label: `Yearly on ${format(launch, 'MMM d')}` },
|
||||
...(recurrence.frequency === 'custom' ? [{ value: 'custom', label: 'Custom' }] : []),
|
||||
]
|
||||
|
||||
/**
|
||||
* Flips the one-time launch into a repeat and back. Toggling off keeps the
|
||||
* recurrence shape (weekdays, end, and a passed-through `custom` cron) on the
|
||||
* object and only collapses `frequency` to `once`; toggling back on reinstates
|
||||
* the remembered cadence, so neither a weekly preset nor a conversationally
|
||||
* authored custom cron is silently rewritten to daily.
|
||||
*/
|
||||
const handleRecurringToggle = (checked: boolean) => {
|
||||
onChange({ ...recurrence, frequency: checked ? lastRecurringFrequency.current : 'once' })
|
||||
}
|
||||
|
||||
const handleFrequencyChange = (value: string) => {
|
||||
const option = value as FrequencyOption
|
||||
switch (option) {
|
||||
case 'daily':
|
||||
onChange({ ...recurrence, frequency: 'daily', weekdays: [], cron: undefined })
|
||||
return
|
||||
case 'weekly':
|
||||
onChange({
|
||||
...recurrence,
|
||||
frequency: 'weekly',
|
||||
weekdays: [launch.getDay()],
|
||||
cron: undefined,
|
||||
})
|
||||
return
|
||||
case 'weekdays':
|
||||
onChange({
|
||||
...recurrence,
|
||||
frequency: 'weekly',
|
||||
weekdays: [...WEEKDAY_PRESET],
|
||||
cron: undefined,
|
||||
})
|
||||
return
|
||||
case 'monthly':
|
||||
onChange({
|
||||
...recurrence,
|
||||
frequency: 'monthly',
|
||||
weekdays: [],
|
||||
monthlyMode: recurrence.monthlyMode ?? 'day-of-month',
|
||||
cron: undefined,
|
||||
})
|
||||
return
|
||||
case 'yearly':
|
||||
onChange({ ...recurrence, frequency: 'yearly', weekdays: [], cron: undefined })
|
||||
return
|
||||
case 'custom':
|
||||
onChange({ ...recurrence, frequency: 'custom' })
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggles a weekday on or off, never letting the last selected day be cleared. */
|
||||
const handleWeekdayToggle = (day: number) => {
|
||||
const isSelected = selectedWeekdays.includes(day)
|
||||
if (isSelected && selectedWeekdays.length === 1) return
|
||||
const weekdays = isSelected
|
||||
? selectedWeekdays.filter((d) => d !== day)
|
||||
: [...selectedWeekdays, day].sort((a, b) => a - b)
|
||||
onChange({ ...recurrence, weekdays })
|
||||
}
|
||||
|
||||
const handleEndChange = (value: string) => {
|
||||
if (value === 'never') onChange({ ...recurrence, end: { type: 'never' } })
|
||||
else if (value === 'on')
|
||||
onChange({ ...recurrence, end: { type: 'on', date: format(launch, 'yyyy-MM-dd') } })
|
||||
else {
|
||||
const count = recurrence.end.type === 'after' ? recurrence.end.count : DEFAULT_END_AFTER_COUNT
|
||||
onChange({ ...recurrence, end: { type: 'after', count } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<ChipModalSeparator />
|
||||
<div className='flex flex-col gap-4 px-2 pt-4 pb-4.5'>
|
||||
<ChipModalField type='custom' title='Recurring'>
|
||||
<Switch checked={isRecurring} onCheckedChange={handleRecurringToggle} />
|
||||
</ChipModalField>
|
||||
|
||||
{isRecurring && (
|
||||
<>
|
||||
<ChipModalField
|
||||
type='dropdown'
|
||||
title='Frequency'
|
||||
value={frequencyOptionFor(recurrence)}
|
||||
options={frequencyOptions}
|
||||
onChange={handleFrequencyChange}
|
||||
/>
|
||||
|
||||
{recurrence.frequency === 'weekly' && (
|
||||
<ChipModalField type='custom' title='Repeat on'>
|
||||
{/* A one-row extract of the calendar: seven equal day cells built
|
||||
from the same {@link CalendarDayCell} the date picker uses, so
|
||||
the weekday toggles read as a sibling of the calendar rather than
|
||||
a separate segmented bar. */}
|
||||
<div className='grid grid-cols-7 gap-1'>
|
||||
{WEEKDAYS.map((weekday) => {
|
||||
const selected = selectedWeekdays.includes(weekday.value)
|
||||
return (
|
||||
<CalendarDayCell
|
||||
key={weekday.value}
|
||||
selected={selected}
|
||||
fullWidth
|
||||
aria-pressed={selected}
|
||||
aria-label={weekday.name}
|
||||
onClick={() => handleWeekdayToggle(weekday.value)}
|
||||
>
|
||||
{weekday.short}
|
||||
</CalendarDayCell>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ChipModalField>
|
||||
)}
|
||||
|
||||
{recurrence.frequency === 'monthly' && (
|
||||
<ChipModalField
|
||||
type='dropdown'
|
||||
title='On'
|
||||
value={monthlyValue}
|
||||
options={monthlyOptions}
|
||||
onChange={(value) => onChange({ ...recurrence, monthlyMode: value as MonthlyMode })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChipModalField
|
||||
type='dropdown'
|
||||
title='Ends'
|
||||
value={recurrence.end.type}
|
||||
options={[
|
||||
{ value: 'never', label: 'No end' },
|
||||
{ value: 'on', label: 'Ends on' },
|
||||
{ value: 'after', label: 'Ends after' },
|
||||
]}
|
||||
onChange={handleEndChange}
|
||||
/>
|
||||
|
||||
{recurrence.end.type === 'on' && (
|
||||
<ChipModalField type='custom' title='End date'>
|
||||
<ChipDatePicker
|
||||
value={recurrence.end.date}
|
||||
onChange={(date) => onChange({ ...recurrence, end: { type: 'on', date } })}
|
||||
fullWidth
|
||||
/>
|
||||
</ChipModalField>
|
||||
)}
|
||||
|
||||
{recurrence.end.type === 'after' && (
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Number of runs'
|
||||
value={String(recurrence.end.count)}
|
||||
onChange={(value) => {
|
||||
const count = Math.max(1, Math.floor(Number(value) || 1))
|
||||
onChange({ ...recurrence, end: { type: 'after', count } })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
ChipDatePicker,
|
||||
ChipModal,
|
||||
ChipModalFooter,
|
||||
type ChipModalFooterSlotAction,
|
||||
ChipModalHeader,
|
||||
ChipModalPromptBody,
|
||||
ChipTimePicker,
|
||||
} from '@sim/emcn'
|
||||
import { Calendar } from '@sim/emcn/icons'
|
||||
import { format } from 'date-fns'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { wallClockNow, zonedWallClockToUtc } from '@/lib/core/utils/timezone'
|
||||
import {
|
||||
PromptEditor,
|
||||
usePromptEditor,
|
||||
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
|
||||
import { RecurrenceSection } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section'
|
||||
import type { CalendarSlot } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar'
|
||||
import {
|
||||
DEFAULT_RECURRENCE,
|
||||
type Recurrence,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
|
||||
import { useTimezone } from '@/hooks/queries/general-settings'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
const DEFAULT_TIME = '09:00'
|
||||
const PAST_LAUNCH_MESSAGE = "You can't schedule a one-time task in the past"
|
||||
|
||||
/** Whether a one-time launch has already passed, evaluated in the task's `timezone`. */
|
||||
function isLaunchInPast(launchDate: string, launchTime: string, timezone: string): boolean {
|
||||
return zonedWallClockToUtc(`${launchDate}T${launchTime}`, timezone) < new Date()
|
||||
}
|
||||
|
||||
/**
|
||||
* The next top of the hour in `timezone`, as a `{ date, time }` wall-clock pair.
|
||||
* The `Z` suffix keeps the step pure wall-clock arithmetic (adding an hour and
|
||||
* rolling the date at 23:xx), not a timezone conversion.
|
||||
*/
|
||||
function nextTopOfHour(timezone: string): { date: string; time: string } {
|
||||
const next = new Date(`${wallClockNow(timezone)}:00Z`)
|
||||
next.setUTCMinutes(0, 0, 0)
|
||||
next.setUTCHours(next.getUTCHours() + 1)
|
||||
return { date: next.toISOString().slice(0, 10), time: next.toISOString().slice(11, 16) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the launch date/time for a create. A slot with a specific hour uses it;
|
||||
* a whole-day slot (month cell) and the no-slot header action both default to
|
||||
* the next top of the hour in `timezone` when the target day is today — so the
|
||||
* modal never opens with a past, already-disabled default — and to 9am on a
|
||||
* future day.
|
||||
*/
|
||||
function defaultLaunch(
|
||||
slot: CalendarSlot | null | undefined,
|
||||
timezone: string
|
||||
): { date: string; time: string } {
|
||||
if (slot?.time) return { date: format(slot.date, 'yyyy-MM-dd'), time: slot.time }
|
||||
const upcoming = nextTopOfHour(timezone)
|
||||
if (slot?.date) {
|
||||
const date = format(slot.date, 'yyyy-MM-dd')
|
||||
const today = wallClockNow(timezone).slice(0, 10)
|
||||
return date === today ? upcoming : { date, time: DEFAULT_TIME }
|
||||
}
|
||||
return upcoming
|
||||
}
|
||||
|
||||
/** The data a task create or edit captures. */
|
||||
export interface TaskDraft {
|
||||
prompt: string
|
||||
/** Resources the prompt `@`-mentions / skills it `/`-invokes, when any. */
|
||||
contexts?: ChatContext[]
|
||||
launchDate: string
|
||||
launchTime: string
|
||||
timezone: string
|
||||
recurrence: Recurrence
|
||||
}
|
||||
|
||||
/** Pre-filled fields shared by the edit and duplicate flows. */
|
||||
export interface TaskPrefill {
|
||||
prompt: string
|
||||
/** Stored `@`-mention contexts, re-registered so they carry over. */
|
||||
contexts?: ChatContext[]
|
||||
launchDate: string
|
||||
launchTime: string
|
||||
/** The task's own zone; the modal seeds AND submits in it so unchanged times never drift. */
|
||||
timezone: string
|
||||
recurrence: Recurrence
|
||||
}
|
||||
|
||||
/** Seeds the modal when editing an existing task, recovered from its schedule. */
|
||||
export interface TaskEditSeed extends TaskPrefill {
|
||||
scheduleId: string
|
||||
}
|
||||
|
||||
interface TaskModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Slot seeding a create — the clicked day/time, or `null` from the header action. */
|
||||
slot?: CalendarSlot | null
|
||||
/** Seed for an edit; when set the modal opens in edit mode (Save + Delete). */
|
||||
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). 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
|
||||
}
|
||||
|
||||
/**
|
||||
* The "schedule a task" modal, shared by create (blank, or pre-filled from a
|
||||
* duplicate) and edit (seeded from a task's schedule). The body is the chat
|
||||
* input's editor — so `@` mentions resources and `/` invokes skills exactly like
|
||||
* talking to Sim — followed by the recurrence section; the footer carries the
|
||||
* launch date/time and (edit only) Delete.
|
||||
*/
|
||||
export function TaskModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
slot,
|
||||
edit,
|
||||
prefill,
|
||||
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={handleOpenChange}
|
||||
size='lg'
|
||||
srTitle={edit ? 'Edit scheduled task' : 'New scheduled task'}
|
||||
>
|
||||
<TaskModalContent
|
||||
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
|
||||
* queries from firing on page load and re-seeds from `edit`/`slot` on each open.
|
||||
*/
|
||||
function TaskModalContent({
|
||||
onOpenChange,
|
||||
slot,
|
||||
edit,
|
||||
prefill,
|
||||
onSubmit,
|
||||
onRequestDelete,
|
||||
submitting,
|
||||
setSubmitting,
|
||||
}: TaskModalContentProps) {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const source = edit ?? prefill
|
||||
const accountTimezone = useTimezone()
|
||||
const timezone = source?.timezone ?? accountTimezone
|
||||
const editor = usePromptEditor({ workspaceId, initialValue: source?.prompt })
|
||||
const setContexts = editor.setContexts
|
||||
|
||||
/**
|
||||
* Re-registers a seeded task's stored `@`-mentions once on open: the editor
|
||||
* seeds from `initialValue` text only, never its contexts. Runs once per open
|
||||
* since the dialog's content remounts each time it opens.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (source?.contexts && source.contexts.length > 0) setContexts(source.contexts)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
const seedFromSource =
|
||||
source &&
|
||||
!(
|
||||
source.recurrence.frequency === 'once' &&
|
||||
isLaunchInPast(source.launchDate, source.launchTime, timezone)
|
||||
)
|
||||
const seed = seedFromSource
|
||||
? { date: source.launchDate, time: source.launchTime }
|
||||
: defaultLaunch(slot, timezone)
|
||||
const [launchDate, setLaunchDate] = useState(seed.date)
|
||||
const [launchTime, setLaunchTime] = useState(seed.time)
|
||||
const [recurrence, setRecurrence] = useState<Recurrence>(
|
||||
() => 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
|
||||
* after mount — `useTimezone()` starts on the browser fallback, so without
|
||||
* this the next-top-of-the-hour default (and its past-launch guard) would be
|
||||
* computed in the wrong zone and submitted in the resolved one. No-op once the
|
||||
* user edits the fields, and skipped for slot/edit/duplicate seeds, whose
|
||||
* launch is zone-stable (slot times are zone-independent; source seeds carry
|
||||
* the task's own fixed zone).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (launchEditedRef.current || source || slot) return
|
||||
const next = defaultLaunch(null, timezone)
|
||||
setLaunchDate(next.date)
|
||||
setLaunchTime(next.time)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timezone])
|
||||
|
||||
const editLaunchDate = (date: string) => {
|
||||
launchEditedRef.current = true
|
||||
setLaunchDate(date)
|
||||
}
|
||||
const editLaunchTime = (time: string) => {
|
||||
launchEditedRef.current = true
|
||||
setLaunchTime(time)
|
||||
}
|
||||
|
||||
const close = () => onOpenChange(false)
|
||||
const isOneTime = recurrence.frequency === 'once'
|
||||
const isPastLaunch = isOneTime && isLaunchInPast(launchDate, launchTime, timezone)
|
||||
|
||||
const promptText = editor.value.trim()
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer secondary actions — the launch date/time pickers and (edit only)
|
||||
* Delete. 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. Recurrence lives in the body, not here.
|
||||
*/
|
||||
const secondaryActions: ChipModalFooterSlotAction[] = [
|
||||
...(edit && onRequestDelete
|
||||
? [
|
||||
{
|
||||
label: 'Delete',
|
||||
variant: 'destructive' as const,
|
||||
onClick: onRequestDelete,
|
||||
disabled: submitting,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ custom: <ChipDatePicker value={launchDate} onChange={editLaunchDate} flush /> },
|
||||
{ custom: <ChipTimePicker value={launchTime} onChange={editLaunchTime} flush /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChipModalHeader icon={Calendar} onClose={close}>
|
||||
{edit ? 'Edit scheduled task' : 'New scheduled task'}
|
||||
</ChipModalHeader>
|
||||
<ChipModalPromptBody>
|
||||
<PromptEditor
|
||||
editor={editor}
|
||||
placeholder='Use @ and launch Sim to...'
|
||||
autoFocus
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</ChipModalPromptBody>
|
||||
<RecurrenceSection recurrence={recurrence} onChange={setRecurrence} launchDate={launchDate} />
|
||||
<ChipModalFooter
|
||||
onCancel={close}
|
||||
cancelDisabled={submitting}
|
||||
secondaryActions={secondaryActions}
|
||||
primaryAction={{
|
||||
label: submitting ? (edit ? 'Saving...' : 'Scheduling...') : edit ? 'Save' : 'Schedule',
|
||||
onClick: handleSubmit,
|
||||
disabled: !promptText || isPastLaunch || submitting,
|
||||
disabledTooltip: isPastLaunch ? PAST_LAUNCH_MESSAGE : undefined,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user