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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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,
}
}