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:
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { isSameDay } from 'date-fns'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import { zonedClockDate } from '@/lib/core/utils/timezone'
|
||||
import {
|
||||
calendarParsers,
|
||||
calendarUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/search-params'
|
||||
import {
|
||||
advanceAnchor,
|
||||
type CalendarScope,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
|
||||
|
||||
/** How often to check whether the calendar day has rolled over. */
|
||||
const DAY_ROLLOVER_POLL_MS = 60_000
|
||||
|
||||
/** A clicked calendar position: a day, optionally narrowed to an hour slot. */
|
||||
export interface CalendarSlot {
|
||||
date: Date
|
||||
/** `HH:mm` when a time slot was clicked; absent for a whole-day click. */
|
||||
time?: string
|
||||
}
|
||||
|
||||
export interface UseCalendarReturn {
|
||||
scope: CalendarScope
|
||||
/** The focused day; week/month ranges derive from it. */
|
||||
anchor: Date
|
||||
/** The current calendar day, shared by every view; refreshes at midnight. */
|
||||
today: Date
|
||||
selectedSlot: CalendarSlot | null
|
||||
isCreateOpen: boolean
|
||||
setScope: (scope: CalendarScope) => void
|
||||
next: () => void
|
||||
prev: () => void
|
||||
goToday: () => void
|
||||
/** Jumps the view to an arbitrary day, keeping the current scope. */
|
||||
goToDate: (date: Date) => void
|
||||
/** Jumps to a day AND switches to the day scope (month-cell overflow drill-in). */
|
||||
openDay: (date: Date) => void
|
||||
selectSlot: (date: Date, time?: string) => void
|
||||
openCreate: () => void
|
||||
closeCreate: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the calendar's view state. `scope` and `anchor` live in the URL (nuqs) so
|
||||
* the current view is shareable and survives reload / back-forward; the create
|
||||
* modal and selected slot stay local (ephemeral UI). Opens on the `week` scope.
|
||||
* "Now" (the today highlight, the default anchor) is resolved in `timezone` — the
|
||||
* viewer's effective zone — so the calendar's date frame matches the zone tasks
|
||||
* are scheduled in. The `anchor` param is date-only and nullable: a clean URL
|
||||
* means "today", derived per-timezone here, so navigating to today clears the
|
||||
* param. `today` is polled so the today highlight and current-time column survive
|
||||
* midnight without a remount; the poll only re-renders when the day actually
|
||||
* changes (the interval is resilient to device sleep, unlike a one-shot timeout
|
||||
* aimed at midnight).
|
||||
*/
|
||||
export function useCalendar(timezone: string): UseCalendarReturn {
|
||||
const timezoneRef = useRef(timezone)
|
||||
const [today, setToday] = useState<Date>(() => zonedClockDate(new Date(), timezone))
|
||||
const [{ scope, anchor: anchorParam }, setCalendarState] = useQueryStates(
|
||||
calendarParsers,
|
||||
calendarUrlKeys
|
||||
)
|
||||
const [selectedSlot, setSelectedSlot] = useState<CalendarSlot | null>(null)
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
const todayRef = useRef(today)
|
||||
|
||||
/** A clean URL (no `anchor` param) means "today", resolved in the effective zone. */
|
||||
const anchor = anchorParam ?? today
|
||||
const anchorRef = useRef(anchor)
|
||||
|
||||
useEffect(() => {
|
||||
todayRef.current = today
|
||||
}, [today])
|
||||
useEffect(() => {
|
||||
anchorRef.current = anchor
|
||||
}, [anchor])
|
||||
|
||||
const setScope = useCallback(
|
||||
(next: CalendarScope) => {
|
||||
void setCalendarState({ scope: next })
|
||||
},
|
||||
[setCalendarState]
|
||||
)
|
||||
|
||||
/**
|
||||
* Set the focused day. Writing `today` (the default anchor) as `null` keeps the
|
||||
* URL clean and preserves the "clean URL = today" invariant.
|
||||
*/
|
||||
const setAnchorDate = useCallback(
|
||||
(date: Date) => {
|
||||
void setCalendarState({ anchor: isSameDay(date, todayRef.current) ? null : date })
|
||||
},
|
||||
[setCalendarState]
|
||||
)
|
||||
|
||||
/**
|
||||
* Re-sync `today` to the effective zone's current day when `timezone` actually
|
||||
* changes — e.g. when `useTimezone()` resolves from the browser fallback to the
|
||||
* saved account zone after mount. When the URL holds an explicit anchor that
|
||||
* was on the previous "today", drop it so the view follows to the new today;
|
||||
* an in-progress navigation (anchor on another day) is preserved.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (timezoneRef.current === timezone) return
|
||||
timezoneRef.current = timezone
|
||||
const now = zonedClockDate(new Date(), timezone)
|
||||
if (anchorRef.current && isSameDay(anchorRef.current, todayRef.current)) {
|
||||
void setCalendarState({ anchor: null })
|
||||
}
|
||||
setToday(now)
|
||||
}, [timezone, setCalendarState])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const now = zonedClockDate(new Date(), timezoneRef.current)
|
||||
setToday((current) => (isSameDay(current, now) ? current : now))
|
||||
}, DAY_ROLLOVER_POLL_MS)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const next = useCallback(
|
||||
() => setAnchorDate(advanceAnchor(anchorRef.current, scope, 1)),
|
||||
[scope, setAnchorDate]
|
||||
)
|
||||
const prev = useCallback(
|
||||
() => setAnchorDate(advanceAnchor(anchorRef.current, scope, -1)),
|
||||
[scope, setAnchorDate]
|
||||
)
|
||||
const goToday = useCallback(
|
||||
() => setAnchorDate(zonedClockDate(new Date(), timezoneRef.current)),
|
||||
[setAnchorDate]
|
||||
)
|
||||
const goToDate = useCallback((date: Date) => setAnchorDate(date), [setAnchorDate])
|
||||
|
||||
const openDay = useCallback(
|
||||
(date: Date) => {
|
||||
void setCalendarState({
|
||||
anchor: isSameDay(date, todayRef.current) ? null : date,
|
||||
scope: 'day',
|
||||
})
|
||||
},
|
||||
[setCalendarState]
|
||||
)
|
||||
|
||||
const selectSlot = useCallback((date: Date, time?: string) => {
|
||||
setSelectedSlot({ date, time })
|
||||
setIsCreateOpen(true)
|
||||
}, [])
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
setSelectedSlot(null)
|
||||
setIsCreateOpen(true)
|
||||
}, [])
|
||||
|
||||
const closeCreate = useCallback(() => {
|
||||
setIsCreateOpen(false)
|
||||
setSelectedSlot(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
scope,
|
||||
anchor,
|
||||
today,
|
||||
selectedSlot,
|
||||
isCreateOpen,
|
||||
setScope,
|
||||
next,
|
||||
prev,
|
||||
goToday,
|
||||
goToDate,
|
||||
openDay,
|
||||
selectSlot,
|
||||
openCreate,
|
||||
closeCreate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import type { CreateScheduleBody, UpdateScheduleBody } from '@/lib/api/contracts/schedules'
|
||||
import { zonedWallClock } from '@/lib/core/utils/timezone'
|
||||
import type {
|
||||
TaskDraft,
|
||||
TaskEditSeed,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal'
|
||||
import {
|
||||
cronToRecurrence,
|
||||
recurrenceToScheduleFields,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
|
||||
import {
|
||||
bucketEventsByDay,
|
||||
type CalendarEvent,
|
||||
type ScheduledTask,
|
||||
scheduleToTasks,
|
||||
taskToCalendarEvent,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
|
||||
import {
|
||||
useCreateSchedule,
|
||||
useDeleteSchedule,
|
||||
useDisableSchedule,
|
||||
useExcludeOccurrence,
|
||||
useResumeSchedule,
|
||||
useUpdateSchedule,
|
||||
useWorkspaceSchedules,
|
||||
} from '@/hooks/queries/schedules'
|
||||
|
||||
/** Job title shown in audit logs / listings, derived from the prompt the user wrote. */
|
||||
function titleFromPrompt(prompt: string): string {
|
||||
return truncate(prompt.trim(), 80) || 'Scheduled task'
|
||||
}
|
||||
|
||||
function draftToCreateBody(draft: TaskDraft, workspaceId: string): CreateScheduleBody {
|
||||
const fields = recurrenceToScheduleFields(
|
||||
draft.recurrence,
|
||||
draft.launchDate,
|
||||
draft.launchTime,
|
||||
draft.timezone
|
||||
)
|
||||
return {
|
||||
workspaceId,
|
||||
title: titleFromPrompt(draft.prompt),
|
||||
prompt: draft.prompt,
|
||||
cronExpression: fields.cronExpression ?? undefined,
|
||||
time: fields.time,
|
||||
timezone: draft.timezone,
|
||||
lifecycle: fields.lifecycle,
|
||||
maxRuns: fields.maxRuns,
|
||||
endsAt: fields.endsAt,
|
||||
contexts: draft.contexts,
|
||||
}
|
||||
}
|
||||
|
||||
/** Edit always sends every recurrence field so clearing an end boundary or switching cadence sticks. */
|
||||
function draftToUpdateBody(draft: TaskDraft): Omit<UpdateScheduleBody, 'action'> {
|
||||
const fields = recurrenceToScheduleFields(
|
||||
draft.recurrence,
|
||||
draft.launchDate,
|
||||
draft.launchTime,
|
||||
draft.timezone
|
||||
)
|
||||
return {
|
||||
title: titleFromPrompt(draft.prompt),
|
||||
prompt: draft.prompt,
|
||||
cronExpression: fields.cronExpression,
|
||||
time: fields.time,
|
||||
timezone: draft.timezone,
|
||||
lifecycle: fields.lifecycle,
|
||||
maxRuns: fields.maxRuns ?? null,
|
||||
endsAt: fields.endsAt ?? null,
|
||||
contexts: draft.contexts ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseScheduledTasksParams {
|
||||
workspaceId: string
|
||||
/** Inclusive window the current view renders; bounds recurrence expansion. */
|
||||
rangeStart: Date
|
||||
rangeEnd: Date
|
||||
}
|
||||
|
||||
export interface UseScheduledTasksReturn {
|
||||
isLoading: boolean
|
||||
/** Day-bucketed events feeding both the month grid and the time grid. */
|
||||
eventsByDay: Map<string, CalendarEvent[]>
|
||||
/** The task occurrence whose modal is open, or `null` when none is. */
|
||||
selectedTask: ScheduledTask | null
|
||||
openTask: (task: ScheduledTask) => void
|
||||
closeTask: () => void
|
||||
/** Recovers the modal's edit seed (recurrence, launch) from a task's schedule. */
|
||||
editSeedFor: (task: ScheduledTask) => TaskEditSeed | null
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the calendar to the persisted job-schedule backend: reads the
|
||||
* workspace's scheduled tasks, expands them into the occurrences visible in the
|
||||
* current range, and exposes create/edit/delete mutations. UI-only selection
|
||||
* state lives here; all task data flows through React Query.
|
||||
*/
|
||||
export function useScheduledTasks({
|
||||
workspaceId,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
}: UseScheduledTasksParams): UseScheduledTasksReturn {
|
||||
const { data: schedules = [], isLoading } = useWorkspaceSchedules(workspaceId)
|
||||
const createSchedule = useCreateSchedule()
|
||||
const updateSchedule = useUpdateSchedule()
|
||||
const deleteSchedule = useDeleteSchedule()
|
||||
const excludeOccurrence = useExcludeOccurrence()
|
||||
const disableSchedule = useDisableSchedule()
|
||||
const resumeSchedule = useResumeSchedule()
|
||||
|
||||
const [selectedTask, setSelectedTask] = useState<ScheduledTask | null>(null)
|
||||
|
||||
const events = useMemo(() => {
|
||||
const now = new Date()
|
||||
return schedules
|
||||
.filter((schedule) => schedule.sourceType === 'job')
|
||||
.flatMap((schedule) => scheduleToTasks(schedule, rangeStart, rangeEnd, now))
|
||||
.map(taskToCalendarEvent)
|
||||
.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||
}, [schedules, rangeStart, rangeEnd])
|
||||
|
||||
const eventsByDay = useMemo(() => bucketEventsByDay(events), [events])
|
||||
|
||||
const openTask = useCallback((task: ScheduledTask) => setSelectedTask(task), [])
|
||||
const closeTask = useCallback(() => setSelectedTask(null), [])
|
||||
|
||||
const editSeedFor = useCallback(
|
||||
(task: ScheduledTask): TaskEditSeed | null => {
|
||||
const schedule = schedules.find((row) => row.id === task.scheduleId)
|
||||
if (!schedule) return null
|
||||
const { recurrence, launchTime } = cronToRecurrence({
|
||||
cronExpression: schedule.cronExpression,
|
||||
maxRuns: schedule.maxRuns,
|
||||
endsAt: schedule.endsAt,
|
||||
anchor: task.runAt,
|
||||
timezone: schedule.timezone,
|
||||
})
|
||||
return {
|
||||
scheduleId: schedule.id,
|
||||
prompt: schedule.prompt ?? '',
|
||||
contexts: task.contexts,
|
||||
launchDate: zonedWallClock(task.runAt, schedule.timezone).slice(0, 10),
|
||||
launchTime,
|
||||
timezone: schedule.timezone,
|
||||
recurrence,
|
||||
}
|
||||
},
|
||||
[schedules]
|
||||
)
|
||||
|
||||
const createTask = useCallback(
|
||||
async (draft: TaskDraft) => {
|
||||
await createSchedule.mutateAsync(draftToCreateBody(draft, workspaceId))
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const updateTask = useCallback(
|
||||
async (scheduleId: string, draft: TaskDraft) => {
|
||||
await updateSchedule.mutateAsync({ scheduleId, workspaceId, ...draftToUpdateBody(draft) })
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const deleteTask = useCallback(
|
||||
(scheduleId: string) => {
|
||||
deleteSchedule.mutate({ scheduleId, workspaceId })
|
||||
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const deleteOccurrence = useCallback(
|
||||
(scheduleId: string, occurrence: Date) => {
|
||||
excludeOccurrence.mutate({ scheduleId, workspaceId, occurrence: occurrence.toISOString() })
|
||||
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[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,
|
||||
selectedTask,
|
||||
openTask,
|
||||
closeTask,
|
||||
editSeedFor,
|
||||
createTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
deleteOccurrence,
|
||||
pauseTask,
|
||||
resumeTask,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user