diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py
index 578b362e..b8acd7c9 100644
--- a/backend/apps/agents/schedule_mcp_server.py
+++ b/backend/apps/agents/schedule_mcp_server.py
@@ -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:
diff --git a/backend/apps/workflows/scheduler.py b/backend/apps/workflows/scheduler.py
index 7fa620a6..28dd949f 100644
--- a/backend/apps/workflows/scheduler.py
+++ b/backend/apps/workflows/scheduler.py
@@ -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()
diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py
index 543d5579..d4210a5a 100644
--- a/backend/apps/workflows/workflows.py
+++ b/backend/apps/workflows/workflows.py
@@ -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 "
diff --git a/backend/tests/test_workflows_semantics.py b/backend/tests/test_workflows_semantics.py
index 491ea7b4..a4b53d3e 100644
--- a/backend/tests/test_workflows_semantics.py
+++ b/backend/tests/test_workflows_semantics.py
@@ -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():
diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx
index 0ac97031..38851d86 100644
--- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx
+++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx
@@ -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
);
- // 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([]);
+ 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();
- 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';
diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx
index 3bbe53ac..dc603908 100644
--- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx
+++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx
@@ -144,6 +144,23 @@ const WorkflowsHubCard: React.FC = ({
const [view, setView] = useState('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.