mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[aidan] feat/schedule-calendar: add calendar occurrences endpoint and concrete timezones
This commit is contained in:
@@ -15,6 +15,7 @@ import os
|
||||
import uuid
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
|
||||
@@ -23,6 +24,20 @@ PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
|
||||
DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
|
||||
|
||||
|
||||
def _local_timezone_name() -> str:
|
||||
name = os.environ.get("OPENSWARM_TIMEZONE", "").strip()
|
||||
if not name:
|
||||
try:
|
||||
from tzlocal import get_localzone_name # type: ignore
|
||||
name = get_localzone_name() or ""
|
||||
except Exception:
|
||||
name = ""
|
||||
try:
|
||||
return (getattr(ZoneInfo(name), "key", None) or "UTC") if name else "UTC"
|
||||
except ZoneInfoNotFoundError:
|
||||
return "UTC"
|
||||
|
||||
|
||||
PRESETS = {
|
||||
"daily_morning": {"enabled": True, "repeat_unit": "day", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": []},
|
||||
"weekdays_morning": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": [1, 2, 3, 4, 5]},
|
||||
@@ -68,7 +83,7 @@ TOOLS = [
|
||||
"items": {"type": "integer"},
|
||||
"description": "Weekdays (Sun=0..Sat=6) when preset='custom' and repeat_unit='week'.",
|
||||
},
|
||||
"timezone": {"type": "string", "description": "IANA timezone name (e.g. 'America/Los_Angeles'). Omit to use the user's local zone."},
|
||||
"timezone": {"type": "string", "description": "IANA timezone name (e.g. 'America/Los_Angeles'). Omit to use the user's current local zone at scheduling time."},
|
||||
"source_session_id": {"type": "string", "description": "Optional; the chat session this workflow was created from. Inherits its tool surface."},
|
||||
},
|
||||
"required": ["title", "steps", "preset"],
|
||||
@@ -253,7 +268,8 @@ def _call(method: str, path: str, body=None) -> dict:
|
||||
|
||||
|
||||
def _build_schedule_from_preset(preset: str, args: dict) -> dict:
|
||||
base = {"timezone": "local", "on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}
|
||||
local_tz = _local_timezone_name()
|
||||
base = {"timezone": args.get("timezone") or local_tz, "on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}
|
||||
if preset == "custom":
|
||||
return {
|
||||
**base,
|
||||
@@ -263,7 +279,6 @@ def _build_schedule_from_preset(preset: str, args: dict) -> dict:
|
||||
"hour": int(args.get("hour", 9)),
|
||||
"minute": int(args.get("minute", 0)),
|
||||
"on_days": list(args.get("on_days") or []),
|
||||
"timezone": args.get("timezone") or "local",
|
||||
}
|
||||
preset_def = PRESETS.get(preset)
|
||||
if not preset_def:
|
||||
|
||||
@@ -58,6 +58,11 @@ def _host_tz() -> ZoneInfo:
|
||||
return _host_tz_cache
|
||||
|
||||
|
||||
def host_timezone_name() -> str:
|
||||
"""Concrete IANA-ish zone name for schedules created on this host."""
|
||||
return getattr(_host_tz(), "key", None) or "UTC"
|
||||
|
||||
|
||||
def _resolve_tz(tz: str) -> ZoneInfo:
|
||||
if not tz or tz == "local":
|
||||
return _host_tz()
|
||||
@@ -194,6 +199,53 @@ def fires_in_window(wf: Workflow, days: int = 30) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def occurrences_between(
|
||||
wf: Workflow,
|
||||
from_utc: datetime,
|
||||
to_utc: datetime,
|
||||
cap: int = 5000,
|
||||
) -> list[datetime]:
|
||||
"""Return scheduled fire instants in [from_utc, to_utc).
|
||||
|
||||
Calendar previews must use the same timezone-aware recurrence engine as
|
||||
the scheduler. Inputs and outputs are UTC-aware datetimes; callers can
|
||||
render those absolute instants in any local timezone.
|
||||
"""
|
||||
sched = wf.schedule
|
||||
if not sched.enabled or not is_schedule_configured(sched):
|
||||
return []
|
||||
if sched.max_runs is not None and sched.runs_count >= sched.max_runs:
|
||||
return []
|
||||
start_utc = _as_utc(from_utc)
|
||||
end_utc = _as_utc(to_utc)
|
||||
if start_utc is None or end_utc is None or end_utc <= start_utc:
|
||||
return []
|
||||
|
||||
created_at = _as_utc(getattr(wf, "created_at", None))
|
||||
cursor_utc = start_utc - timedelta(microseconds=1)
|
||||
if created_at is not None and created_at > cursor_utc:
|
||||
cursor_utc = created_at
|
||||
|
||||
ends_at = _as_utc(sched.ends_at)
|
||||
if ends_at is not None:
|
||||
if ends_at <= start_utc:
|
||||
return []
|
||||
if ends_at < end_utc:
|
||||
end_utc = ends_at
|
||||
|
||||
remaining = sched.max_runs - sched.runs_count if sched.max_runs is not None else cap
|
||||
limit = max(0, min(cap, remaining))
|
||||
out: list[datetime] = []
|
||||
while len(out) < limit:
|
||||
nxt = _next_fire_after(sched, cursor_utc)
|
||||
if nxt is None or nxt >= end_utc:
|
||||
break
|
||||
if nxt >= start_utc:
|
||||
out.append(nxt.astimezone(timezone.utc))
|
||||
cursor_utc = nxt
|
||||
return out
|
||||
|
||||
|
||||
def kick() -> None:
|
||||
_wake.set()
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, Header, Request
|
||||
from fastapi import HTTPException, Header, Query, Request
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.apps.workflows.models import (
|
||||
@@ -144,11 +144,26 @@ async def list_workflows(dashboard_id: Optional[str] = None):
|
||||
|
||||
|
||||
def _normalize_schedule_state(wf: Workflow) -> None:
|
||||
if wf.schedule.timezone == "local" and wf.schedule.enabled:
|
||||
wf.schedule.timezone = scheduler.host_timezone_name()
|
||||
if wf.schedule.enabled and not scheduler.is_schedule_configured(wf.schedule):
|
||||
wf.schedule.enabled = False
|
||||
wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None
|
||||
|
||||
|
||||
def _parse_calendar_bound(value: str, label: str) -> datetime:
|
||||
raw = (value or "").strip()
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid {label} timestamp")
|
||||
if dt.tzinfo is None:
|
||||
raise HTTPException(status_code=400, detail=f"{label} timestamp must include a timezone")
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
@workflows.router.post("/create")
|
||||
async def create_workflow(body: WorkflowCreate):
|
||||
actions = body.actions
|
||||
@@ -507,6 +522,30 @@ async def list_all_runs(limit: int = 200):
|
||||
return {"runs": [r.model_dump(mode="json") for r in runs]}
|
||||
|
||||
|
||||
@workflows.router.get("/calendar")
|
||||
async def list_calendar_events(
|
||||
from_: str = Query(..., alias="from"),
|
||||
to: str = Query(...),
|
||||
dashboard_id: Optional[str] = None,
|
||||
):
|
||||
start_utc = _parse_calendar_bound(from_, "from")
|
||||
end_utc = _parse_calendar_bound(to, "to")
|
||||
if end_utc <= start_utc:
|
||||
raise HTTPException(status_code=400, detail="to must be after from")
|
||||
items = storage.list_workflows()
|
||||
if dashboard_id:
|
||||
items = [w for w in items if not w.dashboard_id or w.dashboard_id == dashboard_id]
|
||||
events: list[dict] = []
|
||||
for wf in items:
|
||||
for fire_at in scheduler.occurrences_between(wf, start_utc, end_utc):
|
||||
events.append({
|
||||
"workflow_id": wf.id,
|
||||
"fire_at": fire_at.astimezone(timezone.utc).isoformat(),
|
||||
})
|
||||
events.sort(key=lambda e: (e["fire_at"], e["workflow_id"]))
|
||||
return {"events": events}
|
||||
|
||||
|
||||
@workflows.router.get("/{workflow_id}")
|
||||
async def get_workflow(workflow_id: str):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
@@ -955,11 +994,12 @@ async def schedule_agent_session(workflow_id: str):
|
||||
from backend.apps.agents.core.models import AgentConfig
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
now_local = datetime.now().astimezone()
|
||||
local_tz = scheduler.host_timezone_name()
|
||||
current_dt = now_local.strftime("%A %Y-%m-%d %H:%M %Z")
|
||||
system_prompt = (
|
||||
f"You are the Scheduling Agent for the user's saved workflow \"{wf.title}\" "
|
||||
f"(id: {wf.id}). Your only job is to set when this workflow runs.\n\n"
|
||||
f"The current local date and time is {current_dt}. Resolve relative "
|
||||
f"The current local date and time is {current_dt} in {local_tz}. Resolve relative "
|
||||
"phrasing (\"this month\", \"next Wednesday\", \"this time\") against it.\n\n"
|
||||
"When the user states a cadence, interpret it yourself and call "
|
||||
"UpdateScheduledWorkflow with:\n"
|
||||
@@ -970,7 +1010,7 @@ async def schedule_agent_session(workflow_id: str):
|
||||
" - repeat_every: the interval count (1 unless they say e.g. \"every other\"; "
|
||||
"for repeat_unit=\"minute\" the minimum is 15, e.g. \"every 15 minutes\")\n"
|
||||
" - on_days: weekday indices when repeat_unit=\"week\" (Sun=0, Mon=1, ... Sat=6)\n"
|
||||
" - timezone: an IANA name only if the user names a specific zone\n\n"
|
||||
f" - timezone: \"{local_tz}\" unless the user names a different specific zone\n\n"
|
||||
"If no AM/PM is given, assume PM for 1-7 and AM for 8-12. If the cadence "
|
||||
"is genuinely ambiguous, ask ONE short clarifying question first; otherwise "
|
||||
"go straight to the tool call. The user approves or rejects the change in a "
|
||||
|
||||
@@ -189,6 +189,122 @@ def test_month_repeat_no_longer_clamps_to_28():
|
||||
assert nxt.astimezone(tz).date() == datetime(2025, 4, 30).date()
|
||||
|
||||
|
||||
def test_calendar_occurrences_use_schedule_timezone_not_viewer_timezone():
|
||||
"""A 9am New York schedule returns UTC instants. The frontend can then
|
||||
render those instants in the viewer's current timezone."""
|
||||
from backend.apps.workflows import scheduler
|
||||
from backend.apps.workflows.models import ScheduleConfig
|
||||
wf = _make_wf(
|
||||
schedule=ScheduleConfig(
|
||||
enabled=True,
|
||||
repeat_unit="day",
|
||||
repeat_every=1,
|
||||
hour=9,
|
||||
minute=0,
|
||||
timezone="America/New_York",
|
||||
)
|
||||
)
|
||||
wf.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
start = datetime(2026, 6, 18, 0, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 6, 20, 0, 0, tzinfo=timezone.utc)
|
||||
fires = scheduler.occurrences_between(wf, start, end)
|
||||
assert len(fires) == 2
|
||||
assert fires[0].astimezone(ZoneInfo("America/New_York")).hour == 9
|
||||
assert fires[0].astimezone(ZoneInfo("America/Los_Angeles")).hour == 6
|
||||
|
||||
|
||||
def test_calendar_occurrences_stay_wall_clock_across_dst():
|
||||
from backend.apps.workflows import scheduler
|
||||
from backend.apps.workflows.models import ScheduleConfig
|
||||
wf = _make_wf(
|
||||
schedule=ScheduleConfig(
|
||||
enabled=True,
|
||||
repeat_unit="day",
|
||||
repeat_every=1,
|
||||
hour=9,
|
||||
minute=0,
|
||||
timezone="America/New_York",
|
||||
)
|
||||
)
|
||||
wf.created_at = datetime(2025, 1, 1, tzinfo=timezone.utc)
|
||||
fires = scheduler.occurrences_between(
|
||||
wf,
|
||||
datetime(2025, 3, 8, 0, 0, tzinfo=timezone.utc),
|
||||
datetime(2025, 3, 11, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
ny = ZoneInfo("America/New_York")
|
||||
locals_ = [f.astimezone(ny) for f in fires]
|
||||
assert [d.date() for d in locals_] == [
|
||||
datetime(2025, 3, 8).date(),
|
||||
datetime(2025, 3, 9).date(),
|
||||
datetime(2025, 3, 10).date(),
|
||||
]
|
||||
assert all((d.hour, d.minute) == (9, 0) for d in locals_)
|
||||
assert [f.hour for f in fires] == [14, 13, 13]
|
||||
|
||||
|
||||
def test_calendar_occurrences_honor_end_conditions():
|
||||
from backend.apps.workflows import scheduler
|
||||
from backend.apps.workflows.models import ScheduleConfig
|
||||
start = datetime(2026, 6, 18, 0, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 6, 22, 0, 0, tzinfo=timezone.utc)
|
||||
wf = _make_wf(
|
||||
schedule=ScheduleConfig(
|
||||
enabled=True,
|
||||
repeat_unit="day",
|
||||
repeat_every=1,
|
||||
hour=9,
|
||||
minute=0,
|
||||
timezone="UTC",
|
||||
max_runs=3,
|
||||
runs_count=1,
|
||||
ends_at=datetime(2026, 6, 21, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
)
|
||||
wf.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
fires = scheduler.occurrences_between(wf, start, end)
|
||||
assert [f.date() for f in fires] == [
|
||||
datetime(2026, 6, 18).date(),
|
||||
datetime(2026, 6, 19).date(),
|
||||
]
|
||||
|
||||
wf.schedule.enabled = False
|
||||
assert scheduler.occurrences_between(wf, start, end) == []
|
||||
|
||||
wf.schedule.enabled = True
|
||||
wf.schedule.repeat_unit = "week"
|
||||
wf.schedule.on_days = []
|
||||
assert scheduler.occurrences_between(wf, start, end) == []
|
||||
|
||||
|
||||
def test_calendar_endpoint_returns_sorted_utc_events():
|
||||
from backend.apps.workflows import storage
|
||||
from backend.apps.workflows.workflows import list_calendar_events
|
||||
from backend.apps.workflows.models import ScheduleConfig
|
||||
wf = _make_wf(
|
||||
schedule=ScheduleConfig(
|
||||
enabled=True,
|
||||
repeat_unit="day",
|
||||
repeat_every=1,
|
||||
hour=9,
|
||||
minute=0,
|
||||
timezone="America/New_York",
|
||||
)
|
||||
)
|
||||
wf.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
storage.save_workflow(wf)
|
||||
|
||||
async def runner():
|
||||
return await list_calendar_events(
|
||||
from_="2026-06-18T00:00:00+00:00",
|
||||
to="2026-06-20T00:00:00+00:00",
|
||||
)
|
||||
|
||||
res = asyncio.new_event_loop().run_until_complete(runner())
|
||||
assert [e["workflow_id"] for e in res["events"]] == [wf.id, wf.id]
|
||||
assert res["events"][0]["fire_at"].startswith("2026-06-18T13:00:00")
|
||||
|
||||
|
||||
# --- Cost cap ----------------------------------------------------------------
|
||||
|
||||
def test_cost_cap_skips_with_clear_error(monkeypatch):
|
||||
@@ -243,6 +359,59 @@ def test_freeze_not_forced_when_source_session_present():
|
||||
assert result["actions"]["freeze"] is False
|
||||
|
||||
|
||||
def test_create_enabled_schedule_normalizes_local_timezone(monkeypatch):
|
||||
from backend.apps.workflows import scheduler
|
||||
from backend.apps.workflows.workflows import create_workflow
|
||||
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig
|
||||
monkeypatch.setenv("OPENSWARM_TIMEZONE", "America/Chicago")
|
||||
monkeypatch.setattr(scheduler, "_host_tz_cache", None)
|
||||
body = WorkflowCreate(
|
||||
title="local-tz-create",
|
||||
schedule=ScheduleConfig(
|
||||
enabled=True,
|
||||
repeat_unit="day",
|
||||
repeat_every=1,
|
||||
hour=9,
|
||||
minute=0,
|
||||
timezone="local",
|
||||
),
|
||||
)
|
||||
result = asyncio.new_event_loop().run_until_complete(create_workflow(body))
|
||||
assert result["schedule"]["timezone"] == "America/Chicago"
|
||||
|
||||
|
||||
def test_enable_schedule_normalizes_local_timezone_and_preserves_concrete_timezone(monkeypatch):
|
||||
from backend.apps.workflows import storage, scheduler
|
||||
from backend.apps.workflows.workflows import update_workflow
|
||||
from backend.apps.workflows.models import WorkflowUpdate, ScheduleConfig
|
||||
monkeypatch.setenv("OPENSWARM_TIMEZONE", "America/Denver")
|
||||
monkeypatch.setattr(scheduler, "_host_tz_cache", None)
|
||||
|
||||
wf = _make_wf()
|
||||
wf.schedule.enabled = False
|
||||
wf.schedule.timezone = "local"
|
||||
storage.save_workflow(wf)
|
||||
|
||||
async def enable_runner():
|
||||
sched = ScheduleConfig(**wf.schedule.model_dump(mode="json"))
|
||||
sched.enabled = True
|
||||
return await update_workflow(wf.id, WorkflowUpdate(schedule=sched), if_match=None)
|
||||
|
||||
enabled = asyncio.new_event_loop().run_until_complete(enable_runner())
|
||||
assert enabled["schedule"]["timezone"] == "America/Denver"
|
||||
|
||||
stored = storage.get_workflow(wf.id)
|
||||
sched = ScheduleConfig(**stored.schedule.model_dump(mode="json"))
|
||||
sched.hour = 10
|
||||
|
||||
async def edit_runner():
|
||||
return await update_workflow(wf.id, WorkflowUpdate(schedule=sched), if_match=None)
|
||||
|
||||
edited = asyncio.new_event_loop().run_until_complete(edit_runner())
|
||||
assert edited["schedule"]["timezone"] == "America/Denver"
|
||||
assert edited["schedule"]["hour"] == 10
|
||||
|
||||
|
||||
# --- Audit log ---------------------------------------------------------------
|
||||
|
||||
def test_audit_log_records_title_change():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
@@ -7,10 +7,11 @@ import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, fireTimesWithin, formatTime, formatHourLabel, isScheduleActive } from './scheduleUtils';
|
||||
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, formatTime, formatHourLabel } from './scheduleUtils';
|
||||
|
||||
interface Props {
|
||||
view: 'Week' | 'Month' | 'List';
|
||||
@@ -24,6 +25,11 @@ interface Props {
|
||||
// starting hour. The scroll container caps the visible window.
|
||||
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
interface CalendarEvent {
|
||||
workflow_id: string;
|
||||
fire_at: string;
|
||||
}
|
||||
|
||||
export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -74,32 +80,74 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
<MenuItem onClick={onDelete} sx={{ color: c.status.error }}>Delete</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
// refDate is recreated on every render unless the caller memoizes it,
|
||||
// which then trips the eventsByDay memo every paint. Pin the calendar
|
||||
// to a day-precision key so the heavy fireTimesWithin loop only re-runs
|
||||
// when the day or workflow set actually changed.
|
||||
// refDate is recreated on every render unless the caller memoizes it.
|
||||
// Pin the calendar to a day-precision key so occurrence fetches only
|
||||
// change when the visible day, view, or schedule set changes.
|
||||
const today = refDate || new Date();
|
||||
const dayKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
|
||||
const compact = density === 'compact';
|
||||
const range = view === 'Month' ? 35 : view === 'Week' ? 7 : 14;
|
||||
const rangeStart = useMemo(
|
||||
() => view === 'Month' ? startOfMonthGrid(today) : view === 'Week' ? startOfWeek(today) : new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[view, dayKey],
|
||||
);
|
||||
const rangeEndExclusive = useMemo(() => addDays(rangeStart, range), [rangeStart, range]);
|
||||
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
|
||||
const [calendarFetchKey, setCalendarFetchKey] = useState('');
|
||||
const workflowScheduleKey = workflows
|
||||
.map((w) => `${w.id}:${w.updated_at}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
const fromIso = rangeStart.toISOString();
|
||||
const toIso = rangeEndExclusive.toISOString();
|
||||
const calendarRequestKey = `${view}:${fromIso}:${toIso}:${workflowScheduleKey}`;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const ctrl = new AbortController();
|
||||
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`, { signal: ctrl.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`calendar failed ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setCalendarEvents((data.events || []) as CalendarEvent[]);
|
||||
setCalendarFetchKey(calendarRequestKey);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setCalendarEvents([]);
|
||||
setCalendarFetchKey(calendarRequestKey);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ctrl.abort();
|
||||
};
|
||||
}, [fromIso, toIso, calendarRequestKey]);
|
||||
|
||||
const eventsByDay = useMemo(() => {
|
||||
const range = view === 'Month' ? 35 : view === 'Week' ? 7 : 14;
|
||||
const start = view === 'Month' ? startOfMonthGrid(today) : view === 'Week' ? startOfWeek(today) : today;
|
||||
const end = addDays(start, range - 1);
|
||||
const map = new Map<string, { workflow: Workflow; date: Date }[]>();
|
||||
for (const wf of workflows) {
|
||||
if (!isScheduleActive(wf.schedule)) continue;
|
||||
const fires = fireTimesWithin(wf, start, end, 60);
|
||||
for (const d of fires) {
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
const arr = map.get(key) || [];
|
||||
arr.push({ workflow: wf, date: d });
|
||||
map.set(key, arr);
|
||||
}
|
||||
if (calendarFetchKey !== calendarRequestKey) {
|
||||
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
|
||||
}
|
||||
return { map, start, end };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workflows, view, dayKey]);
|
||||
const workflowById = new Map(workflows.map((wf) => [wf.id, wf]));
|
||||
for (const event of calendarEvents) {
|
||||
const wf = workflowById.get(event.workflow_id);
|
||||
if (!wf) continue;
|
||||
const d = new Date(event.fire_at);
|
||||
if (Number.isNaN(d.getTime())) continue;
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
const arr = map.get(key) || [];
|
||||
arr.push({ workflow: wf, date: d });
|
||||
map.set(key, arr);
|
||||
}
|
||||
for (const arr of map.values()) {
|
||||
arr.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
}
|
||||
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
|
||||
}, [calendarEvents, calendarFetchKey, calendarRequestKey, workflows, rangeStart, rangeEndExclusive]);
|
||||
|
||||
const SLOT_H = compact ? 32 : 44;
|
||||
const ROW_LABEL = compact ? '0.7rem' : '0.74rem';
|
||||
|
||||
@@ -144,6 +144,23 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
const [view, setView] = useState<CalendarView>('List');
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [refDate, setRefDate] = useState(new Date());
|
||||
|
||||
// The hub card lives on the canvas for days at a time, so a refDate frozen
|
||||
// at mount leaves the calendar stuck on the day it was opened (e.g. still
|
||||
// showing "yesterday" past midnight). Roll it forward when the day flips,
|
||||
// but only if the user was parked on today, so manual navigation is left be.
|
||||
const refDateRef = useRef(refDate);
|
||||
refDateRef.current = refDate;
|
||||
useEffect(() => {
|
||||
let lastToday = new Date();
|
||||
const id = window.setInterval(() => {
|
||||
const now = new Date();
|
||||
if (sameDay(now, lastToday)) return;
|
||||
if (sameDay(refDateRef.current, lastToday)) setRefDate(now);
|
||||
lastToday = now;
|
||||
}, 60000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
// Right-click on a sidebar row opens this menu pinned to the cursor.
|
||||
|
||||
Reference in New Issue
Block a user