diff --git a/autogpt_platform/backend/backend/blocks/time_blocks.py b/autogpt_platform/backend/backend/blocks/time_blocks.py
index 84d2ef1d0a..df5c34af5c 100644
--- a/autogpt_platform/backend/backend/blocks/time_blocks.py
+++ b/autogpt_platform/backend/backend/blocks/time_blocks.py
@@ -1,4 +1,5 @@
import asyncio
+import logging
import time
from datetime import datetime, timedelta
from typing import Any, Literal, Union
@@ -7,6 +8,7 @@ from zoneinfo import ZoneInfo
from pydantic import BaseModel
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
+from backend.data.execution import UserContext
from backend.data.model import SchemaField
# Shared timezone literal type for all time/date blocks
@@ -51,16 +53,80 @@ TimezoneLiteral = Literal[
"Etc/GMT+12", # UTC-12:00
]
+logger = logging.getLogger(__name__)
+
+
+def _get_timezone(
+ format_type: Any, # Any format type with timezone and use_user_timezone attributes
+ user_timezone: str | None,
+) -> ZoneInfo:
+ """
+ Determine which timezone to use based on format settings and user context.
+
+ Args:
+ format_type: The format configuration containing timezone settings
+ user_timezone: The user's timezone from context
+
+ Returns:
+ ZoneInfo object for the determined timezone
+ """
+ if format_type.use_user_timezone and user_timezone:
+ tz = ZoneInfo(user_timezone)
+ logger.debug(f"Using user timezone: {user_timezone}")
+ else:
+ tz = ZoneInfo(format_type.timezone)
+ logger.debug(f"Using specified timezone: {format_type.timezone}")
+ return tz
+
+
+def _format_datetime_iso8601(dt: datetime, include_microseconds: bool = False) -> str:
+ """
+ Format a datetime object to ISO8601 string.
+
+ Args:
+ dt: The datetime object to format
+ include_microseconds: Whether to include microseconds in the output
+
+ Returns:
+ ISO8601 formatted string
+ """
+ if include_microseconds:
+ return dt.isoformat()
+ else:
+ return dt.isoformat(timespec="seconds")
+
+
+# BACKWARDS COMPATIBILITY NOTE:
+# The timezone field is kept at the format level (not block level) for backwards compatibility.
+# Existing graphs have timezone saved within format_type, moving it would break them.
+#
+# The use_user_timezone flag was added to allow using the user's profile timezone.
+# Default is False to maintain backwards compatibility - existing graphs will continue
+# using their specified timezone.
+#
+# KNOWN ISSUE: If a user switches between format types (strftime <-> iso8601),
+# the timezone setting doesn't carry over. This is a UX issue but fixing it would
+# require either:
+# 1. Moving timezone to block level (breaking change, needs migration)
+# 2. Complex state management to sync timezone across format types
+#
+# Future migration path: When we do a major version bump, consider moving timezone
+# to the block Input level for better UX.
+
class TimeStrftimeFormat(BaseModel):
discriminator: Literal["strftime"]
format: str = "%H:%M:%S"
timezone: TimezoneLiteral = "UTC"
+ # When True, overrides timezone with user's profile timezone
+ use_user_timezone: bool = False
class TimeISO8601Format(BaseModel):
discriminator: Literal["iso8601"]
timezone: TimezoneLiteral = "UTC"
+ # When True, overrides timezone with user's profile timezone
+ use_user_timezone: bool = False
include_microseconds: bool = False
@@ -115,25 +181,27 @@ class GetCurrentTimeBlock(Block):
],
)
- async def run(self, input_data: Input, **kwargs) -> BlockOutput:
+ async def run(
+ self, input_data: Input, *, user_context: UserContext, **kwargs
+ ) -> BlockOutput:
+ # Extract timezone from user_context (always present)
+ effective_timezone = user_context.timezone
+
+ # Get the appropriate timezone
+ tz = _get_timezone(input_data.format_type, effective_timezone)
+ dt = datetime.now(tz=tz)
+
if isinstance(input_data.format_type, TimeISO8601Format):
- # ISO 8601 format for time only (extract time portion from full ISO datetime)
- tz = ZoneInfo(input_data.format_type.timezone)
- dt = datetime.now(tz=tz)
-
# Get the full ISO format and extract just the time portion with timezone
- if input_data.format_type.include_microseconds:
- full_iso = dt.isoformat()
- else:
- full_iso = dt.isoformat(timespec="seconds")
-
+ full_iso = _format_datetime_iso8601(
+ dt, input_data.format_type.include_microseconds
+ )
# Extract time portion (everything after 'T')
current_time = full_iso.split("T")[1] if "T" in full_iso else full_iso
current_time = f"T{current_time}" # Add T prefix for ISO 8601 time format
else: # TimeStrftimeFormat
- tz = ZoneInfo(input_data.format_type.timezone)
- dt = datetime.now(tz=tz)
current_time = dt.strftime(input_data.format_type.format)
+
yield "time", current_time
@@ -141,11 +209,15 @@ class DateStrftimeFormat(BaseModel):
discriminator: Literal["strftime"]
format: str = "%Y-%m-%d"
timezone: TimezoneLiteral = "UTC"
+ # When True, overrides timezone with user's profile timezone
+ use_user_timezone: bool = False
class DateISO8601Format(BaseModel):
discriminator: Literal["iso8601"]
timezone: TimezoneLiteral = "UTC"
+ # When True, overrides timezone with user's profile timezone
+ use_user_timezone: bool = False
class GetCurrentDateBlock(Block):
@@ -217,20 +289,23 @@ class GetCurrentDateBlock(Block):
)
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
+ # Extract timezone from user_context (required keyword argument)
+ user_context: UserContext = kwargs["user_context"]
+ effective_timezone = user_context.timezone
+
try:
offset = int(input_data.offset)
except ValueError:
offset = 0
+ # Get the appropriate timezone
+ tz = _get_timezone(input_data.format_type, effective_timezone)
+ current_date = datetime.now(tz=tz) - timedelta(days=offset)
+
if isinstance(input_data.format_type, DateISO8601Format):
- # ISO 8601 format for date only (YYYY-MM-DD)
- tz = ZoneInfo(input_data.format_type.timezone)
- current_date = datetime.now(tz=tz) - timedelta(days=offset)
# ISO 8601 date format is YYYY-MM-DD
date_str = current_date.date().isoformat()
else: # DateStrftimeFormat
- tz = ZoneInfo(input_data.format_type.timezone)
- current_date = datetime.now(tz=tz) - timedelta(days=offset)
date_str = current_date.strftime(input_data.format_type.format)
yield "date", date_str
@@ -240,11 +315,15 @@ class StrftimeFormat(BaseModel):
discriminator: Literal["strftime"]
format: str = "%Y-%m-%d %H:%M:%S"
timezone: TimezoneLiteral = "UTC"
+ # When True, overrides timezone with user's profile timezone
+ use_user_timezone: bool = False
class ISO8601Format(BaseModel):
discriminator: Literal["iso8601"]
timezone: TimezoneLiteral = "UTC"
+ # When True, overrides timezone with user's profile timezone
+ use_user_timezone: bool = False
include_microseconds: bool = False
@@ -316,20 +395,22 @@ class GetCurrentDateAndTimeBlock(Block):
)
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
+ # Extract timezone from user_context (required keyword argument)
+ user_context: UserContext = kwargs["user_context"]
+ effective_timezone = user_context.timezone
+
+ # Get the appropriate timezone
+ tz = _get_timezone(input_data.format_type, effective_timezone)
+ dt = datetime.now(tz=tz)
+
if isinstance(input_data.format_type, ISO8601Format):
# ISO 8601 format with specified timezone (also RFC3339-compliant)
- tz = ZoneInfo(input_data.format_type.timezone)
- dt = datetime.now(tz=tz)
-
- # Format with or without microseconds
- if input_data.format_type.include_microseconds:
- current_date_time = dt.isoformat()
- else:
- current_date_time = dt.isoformat(timespec="seconds")
+ current_date_time = _format_datetime_iso8601(
+ dt, input_data.format_type.include_microseconds
+ )
else: # StrftimeFormat
- tz = ZoneInfo(input_data.format_type.timezone)
- dt = datetime.now(tz=tz)
current_date_time = dt.strftime(input_data.format_type.format)
+
yield "date_time", current_date_time
diff --git a/autogpt_platform/backend/backend/data/credit_test.py b/autogpt_platform/backend/backend/data/credit_test.py
index d4bf1a3a4d..704939c745 100644
--- a/autogpt_platform/backend/backend/data/credit_test.py
+++ b/autogpt_platform/backend/backend/data/credit_test.py
@@ -7,7 +7,7 @@ from prisma.models import CreditTransaction
from backend.blocks.llm import AITextGeneratorBlock
from backend.data.block import get_block
from backend.data.credit import BetaUserCredit, UsageTransactionMetadata
-from backend.data.execution import NodeExecutionEntry
+from backend.data.execution import NodeExecutionEntry, UserContext
from backend.data.user import DEFAULT_USER_ID
from backend.executor.utils import block_usage_cost
from backend.integrations.credentials_store import openai_credentials
@@ -75,6 +75,7 @@ async def test_block_credit_usage(server: SpinTestServer):
"type": openai_credentials.type,
},
},
+ user_context=UserContext(timezone="UTC"),
),
)
assert spending_amount_1 > 0
@@ -88,6 +89,7 @@ async def test_block_credit_usage(server: SpinTestServer):
node_exec_id="test_node_exec",
block_id=AITextGeneratorBlock().id,
inputs={"model": "gpt-4-turbo", "api_key": "owned_api_key"},
+ user_context=UserContext(timezone="UTC"),
),
)
assert spending_amount_2 == 0
diff --git a/autogpt_platform/backend/backend/data/execution.py b/autogpt_platform/backend/backend/data/execution.py
index 2e484fdc84..2d9fc9c65b 100644
--- a/autogpt_platform/backend/backend/data/execution.py
+++ b/autogpt_platform/backend/backend/data/execution.py
@@ -292,13 +292,14 @@ class GraphExecutionWithNodes(GraphExecution):
node_executions=node_executions,
)
- def to_graph_execution_entry(self):
+ def to_graph_execution_entry(self, user_context: "UserContext"):
return GraphExecutionEntry(
user_id=self.user_id,
graph_id=self.graph_id,
graph_version=self.graph_version or 0,
graph_exec_id=self.id,
nodes_input_masks={}, # FIXME: store credentials on AgentGraphExecution
+ user_context=user_context,
)
@@ -370,7 +371,9 @@ class NodeExecutionResult(BaseModel):
end_time=_node_exec.endedTime,
)
- def to_node_execution_entry(self) -> "NodeExecutionEntry":
+ def to_node_execution_entry(
+ self, user_context: "UserContext"
+ ) -> "NodeExecutionEntry":
return NodeExecutionEntry(
user_id=self.user_id,
graph_exec_id=self.graph_exec_id,
@@ -379,6 +382,7 @@ class NodeExecutionResult(BaseModel):
node_id=self.node_id,
block_id=self.block_id,
inputs=self.input_data,
+ user_context=user_context,
)
@@ -873,12 +877,19 @@ async def get_latest_node_execution(
# ----------------- Execution Infrastructure ----------------- #
+class UserContext(BaseModel):
+ """Generic user context for graph execution containing user-specific settings."""
+
+ timezone: str
+
+
class GraphExecutionEntry(BaseModel):
user_id: str
graph_exec_id: str
graph_id: str
graph_version: int
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None
+ user_context: UserContext
class NodeExecutionEntry(BaseModel):
@@ -889,6 +900,7 @@ class NodeExecutionEntry(BaseModel):
node_id: str
block_id: str
inputs: BlockInput
+ user_context: UserContext
class ExecutionQueue(Generic[T]):
diff --git a/autogpt_platform/backend/backend/data/model.py b/autogpt_platform/backend/backend/data/model.py
index 1b016a9d91..01602ec1d4 100644
--- a/autogpt_platform/backend/backend/data/model.py
+++ b/autogpt_platform/backend/backend/data/model.py
@@ -96,6 +96,12 @@ class User(BaseModel):
default=True, description="Notify on monthly summary"
)
+ # User timezone for scheduling and time display
+ timezone: str = Field(
+ default="not-set",
+ description="User timezone (IANA timezone identifier or 'not-set')",
+ )
+
@classmethod
def from_db(cls, prisma_user: "PrismaUser") -> "User":
"""Convert a database User object to application User model."""
@@ -149,6 +155,7 @@ class User(BaseModel):
notify_on_daily_summary=prisma_user.notifyOnDailySummary or True,
notify_on_weekly_summary=prisma_user.notifyOnWeeklySummary or True,
notify_on_monthly_summary=prisma_user.notifyOnMonthlySummary or True,
+ timezone=prisma_user.timezone or "not-set",
)
diff --git a/autogpt_platform/backend/backend/data/user.py b/autogpt_platform/backend/backend/data/user.py
index d43106df34..3b1dd296db 100644
--- a/autogpt_platform/backend/backend/data/user.py
+++ b/autogpt_platform/backend/backend/data/user.py
@@ -396,3 +396,17 @@ async def unsubscribe_user_by_token(token: str) -> None:
)
except Exception as e:
raise DatabaseError(f"Failed to unsubscribe user by token {token}: {e}") from e
+
+
+async def update_user_timezone(user_id: str, timezone: str) -> User:
+ """Update a user's timezone setting."""
+ try:
+ user = await PrismaUser.prisma().update(
+ where={"id": user_id},
+ data={"timezone": timezone},
+ )
+ if not user:
+ raise ValueError(f"User not found with ID: {user_id}")
+ return User.from_db(user)
+ except Exception as e:
+ raise DatabaseError(f"Failed to update timezone for user {user_id}: {e}") from e
diff --git a/autogpt_platform/backend/backend/executor/manager.py b/autogpt_platform/backend/backend/executor/manager.py
index cc142bacd1..b4695043ea 100644
--- a/autogpt_platform/backend/backend/executor/manager.py
+++ b/autogpt_platform/backend/backend/executor/manager.py
@@ -51,6 +51,7 @@ from backend.data.execution import (
GraphExecutionEntry,
NodeExecutionEntry,
NodeExecutionResult,
+ UserContext,
)
from backend.data.graph import Link, Node
from backend.executor.utils import (
@@ -190,6 +191,9 @@ async def execute_node(
"user_id": user_id,
}
+ # Add user context from NodeExecutionEntry
+ extra_exec_kwargs["user_context"] = data.user_context
+
# Last-minute fetch credentials + acquire a system-wide read-write lock to prevent
# changes during execution. ⚠️ This means a set of credentials can only be used by
# one (running) block at a time; simultaneous execution of blocks using same
@@ -236,6 +240,7 @@ async def _enqueue_next_nodes(
graph_id: str,
log_metadata: LogMetadata,
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]],
+ user_context: UserContext,
) -> list[NodeExecutionEntry]:
async def add_enqueued_execution(
node_exec_id: str, node_id: str, block_id: str, data: BlockInput
@@ -254,6 +259,7 @@ async def _enqueue_next_nodes(
node_id=node_id,
block_id=block_id,
inputs=data,
+ user_context=user_context,
)
async def register_next_executions(node_link: Link) -> list[NodeExecutionEntry]:
@@ -787,7 +793,8 @@ class ExecutionProcessor:
ExecutionStatus.TERMINATED,
],
):
- execution_queue.add(node_exec.to_node_execution_entry())
+ node_entry = node_exec.to_node_execution_entry(graph_exec.user_context)
+ execution_queue.add(node_entry)
# ------------------------------------------------------------
# Main dispatch / polling loop -----------------------------
@@ -1052,6 +1059,7 @@ class ExecutionProcessor:
db_client = get_db_async_client()
log_metadata.debug(f"Enqueue nodes for {node_id}: {output}")
+
for next_execution in await _enqueue_next_nodes(
db_client=db_client,
node=output.node,
@@ -1061,6 +1069,7 @@ class ExecutionProcessor:
graph_id=graph_exec.graph_id,
log_metadata=log_metadata,
nodes_input_masks=nodes_input_masks,
+ user_context=graph_exec.user_context,
):
execution_queue.add(next_execution)
diff --git a/autogpt_platform/backend/backend/executor/scheduler.py b/autogpt_platform/backend/backend/executor/scheduler.py
index e0d5e00ee3..e476b3ceed 100644
--- a/autogpt_platform/backend/backend/executor/scheduler.py
+++ b/autogpt_platform/backend/backend/executor/scheduler.py
@@ -17,6 +17,7 @@ from apscheduler.jobstores.memory import MemoryJobStore
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
+from apscheduler.util import ZoneInfo
from dotenv import load_dotenv
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import MetaData, create_engine
@@ -303,6 +304,7 @@ class Scheduler(AppService):
Jobstores.WEEKLY_NOTIFICATIONS.value: MemoryJobStore(),
},
logger=apscheduler_logger,
+ timezone=ZoneInfo("UTC"),
)
if self.register_system_tasks:
@@ -406,6 +408,8 @@ class Scheduler(AppService):
)
)
+ logger.info(f"Scheduling job for user {user_id} in UTC (cron: {cron})")
+
job_args = GraphExecutionJobArgs(
user_id=user_id,
graph_id=graph_id,
@@ -418,12 +422,12 @@ class Scheduler(AppService):
execute_graph,
kwargs=job_args.model_dump(),
name=name,
- trigger=CronTrigger.from_crontab(cron),
+ trigger=CronTrigger.from_crontab(cron, timezone="UTC"),
jobstore=Jobstores.EXECUTION.value,
replace_existing=True,
)
logger.info(
- f"Added job {job.id} with cron schedule '{cron}' input data: {input_data}"
+ f"Added job {job.id} with cron schedule '{cron}' in UTC, input data: {input_data}"
)
return GraphExecutionJobInfo.from_db(job_args, job)
diff --git a/autogpt_platform/backend/backend/executor/utils.py b/autogpt_platform/backend/backend/executor/utils.py
index fca220a43e..dd4b94a60c 100644
--- a/autogpt_platform/backend/backend/executor/utils.py
+++ b/autogpt_platform/backend/backend/executor/utils.py
@@ -18,10 +18,12 @@ from backend.data.execution import (
ExecutionStatus,
GraphExecutionStats,
GraphExecutionWithNodes,
+ UserContext,
)
from backend.data.graph import GraphModel, Node
from backend.data.model import CredentialsMetaInput
from backend.data.rabbitmq import Exchange, ExchangeType, Queue, RabbitMQConfig
+from backend.data.user import get_user_by_id
from backend.util.clients import (
get_async_execution_event_bus,
get_async_execution_queue,
@@ -34,6 +36,27 @@ from backend.util.mock import MockObject
from backend.util.settings import Config
from backend.util.type import convert
+
+async def get_user_context(user_id: str) -> UserContext:
+ """
+ Get UserContext for a user, always returns a valid context with timezone.
+ Defaults to UTC if user has no timezone set.
+ """
+ user_context = UserContext(timezone="UTC") # Default to UTC
+ try:
+ user = await get_user_by_id(user_id)
+ if user and user.timezone and user.timezone != "not-set":
+ user_context.timezone = user.timezone
+ logger.debug(f"Retrieved user context: timezone={user.timezone}")
+ else:
+ logger.debug("User has no timezone set, using UTC")
+ except Exception as e:
+ logger.warning(f"Could not fetch user timezone: {e}")
+ # Continue with UTC as default
+
+ return user_context
+
+
config = Config()
logger = TruncatedLogger(logging.getLogger(__name__), prefix="[GraphExecutorUtil]")
@@ -877,8 +900,11 @@ async def add_graph_execution(
preset_id=preset_id,
)
+ # Fetch user context for the graph execution
+ user_context = await get_user_context(user_id)
+
queue = await get_async_execution_queue()
- graph_exec_entry = graph_exec.to_graph_execution_entry()
+ graph_exec_entry = graph_exec.to_graph_execution_entry(user_context)
if nodes_input_masks:
graph_exec_entry.nodes_input_masks = nodes_input_masks
diff --git a/autogpt_platform/backend/backend/server/model.py b/autogpt_platform/backend/backend/server/model.py
index fa52694107..8085cb3a81 100644
--- a/autogpt_platform/backend/backend/server/model.py
+++ b/autogpt_platform/backend/backend/server/model.py
@@ -5,6 +5,7 @@ import pydantic
from backend.data.api_key import APIKeyPermission, APIKeyWithoutHash
from backend.data.graph import Graph
+from backend.util.timezone_name import TimeZoneName
class WSMethod(enum.Enum):
@@ -70,3 +71,12 @@ class UploadFileResponse(pydantic.BaseModel):
size: int
content_type: str
expires_in_hours: int
+
+
+class TimezoneResponse(pydantic.BaseModel):
+ # Allow "not-set" as a special value, or any valid IANA timezone
+ timezone: TimeZoneName | str
+
+
+class UpdateTimezoneRequest(pydantic.BaseModel):
+ timezone: TimeZoneName
diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py
index 41cdec3c6b..791b6e526e 100644
--- a/autogpt_platform/backend/backend/server/routers/v1.py
+++ b/autogpt_platform/backend/backend/server/routers/v1.py
@@ -61,9 +61,11 @@ from backend.data.onboarding import (
)
from backend.data.user import (
get_or_create_user,
+ get_user_by_id,
get_user_notification_preference,
update_user_email,
update_user_notification_preference,
+ update_user_timezone,
)
from backend.executor import scheduler
from backend.executor import utils as execution_utils
@@ -78,7 +80,9 @@ from backend.server.model import (
ExecuteGraphResponse,
RequestTopUp,
SetGraphActiveVersion,
+ TimezoneResponse,
UpdatePermissionsRequest,
+ UpdateTimezoneRequest,
UploadFileResponse,
)
from backend.server.utils import get_user_id
@@ -86,6 +90,11 @@ from backend.util.clients import get_scheduler_client
from backend.util.cloud_storage import get_cloud_storage_handler
from backend.util.exceptions import GraphValidationError, NotFoundError
from backend.util.settings import Settings
+from backend.util.timezone_utils import (
+ convert_cron_to_utc,
+ convert_utc_time_to_user_timezone,
+ get_user_timezone_or_utc,
+)
from backend.util.virus_scanner import scan_content_safe
@@ -149,6 +158,35 @@ async def update_user_email_route(
return {"email": email}
+@v1_router.get(
+ "/auth/user/timezone",
+ summary="Get user timezone",
+ tags=["auth"],
+ dependencies=[Depends(auth_middleware)],
+)
+async def get_user_timezone_route(
+ user_data: dict = Depends(auth_middleware),
+) -> TimezoneResponse:
+ """Get user timezone setting."""
+ user = await get_or_create_user(user_data)
+ return TimezoneResponse(timezone=user.timezone)
+
+
+@v1_router.post(
+ "/auth/user/timezone",
+ summary="Update user timezone",
+ tags=["auth"],
+ dependencies=[Depends(auth_middleware)],
+ response_model=TimezoneResponse,
+)
+async def update_user_timezone_route(
+ user_id: Annotated[str, Depends(get_user_id)], request: UpdateTimezoneRequest
+) -> TimezoneResponse:
+ """Update user timezone. The timezone should be a valid IANA timezone identifier."""
+ user = await update_user_timezone(user_id, str(request.timezone))
+ return TimezoneResponse(timezone=user.timezone)
+
+
@v1_router.get(
"/auth/user/preferences",
summary="Get notification preferences",
@@ -933,16 +971,36 @@ async def create_graph_execution_schedule(
detail=f"Graph #{graph_id} v{schedule_params.graph_version} not found.",
)
- return await get_scheduler_client().add_execution_schedule(
+ user = await get_user_by_id(user_id)
+ user_timezone = get_user_timezone_or_utc(user.timezone if user else None)
+
+ # Convert cron expression from user timezone to UTC
+ try:
+ utc_cron = convert_cron_to_utc(schedule_params.cron, user_timezone)
+ except ValueError as e:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid cron expression for timezone {user_timezone}: {e}",
+ )
+
+ result = await get_scheduler_client().add_execution_schedule(
user_id=user_id,
graph_id=graph_id,
graph_version=graph.version,
name=schedule_params.name,
- cron=schedule_params.cron,
+ cron=utc_cron, # Send UTC cron to scheduler
input_data=schedule_params.inputs,
input_credentials=schedule_params.credentials,
)
+ # Convert the next_run_time back to user timezone for display
+ if result.next_run_time:
+ result.next_run_time = convert_utc_time_to_user_timezone(
+ result.next_run_time, user_timezone
+ )
+
+ return result
+
@v1_router.get(
path="/graphs/{graph_id}/schedules",
@@ -954,11 +1012,24 @@ async def list_graph_execution_schedules(
user_id: Annotated[str, Depends(get_user_id)],
graph_id: str = Path(),
) -> list[scheduler.GraphExecutionJobInfo]:
- return await get_scheduler_client().get_execution_schedules(
+ schedules = await get_scheduler_client().get_execution_schedules(
user_id=user_id,
graph_id=graph_id,
)
+ # Get user timezone for conversion
+ user = await get_user_by_id(user_id)
+ user_timezone = get_user_timezone_or_utc(user.timezone if user else None)
+
+ # Convert next_run_time to user timezone for display
+ for schedule in schedules:
+ if schedule.next_run_time:
+ schedule.next_run_time = convert_utc_time_to_user_timezone(
+ schedule.next_run_time, user_timezone
+ )
+
+ return schedules
+
@v1_router.get(
path="/schedules",
@@ -969,7 +1040,20 @@ async def list_graph_execution_schedules(
async def list_all_graphs_execution_schedules(
user_id: Annotated[str, Depends(get_user_id)],
) -> list[scheduler.GraphExecutionJobInfo]:
- return await get_scheduler_client().get_execution_schedules(user_id=user_id)
+ schedules = await get_scheduler_client().get_execution_schedules(user_id=user_id)
+
+ # Get user timezone for conversion
+ user = await get_user_by_id(user_id)
+ user_timezone = get_user_timezone_or_utc(user.timezone if user else None)
+
+ # Convert UTC next_run_time to user timezone for display
+ for schedule in schedules:
+ if schedule.next_run_time:
+ schedule.next_run_time = convert_utc_time_to_user_timezone(
+ schedule.next_run_time, user_timezone
+ )
+
+ return schedules
@v1_router.delete(
diff --git a/autogpt_platform/backend/backend/util/test.py b/autogpt_platform/backend/backend/util/test.py
index 72a39d4027..e2e4cc79b0 100644
--- a/autogpt_platform/backend/backend/util/test.py
+++ b/autogpt_platform/backend/backend/util/test.py
@@ -9,6 +9,7 @@ from backend.data.block import Block, BlockSchema, initialize_blocks
from backend.data.execution import (
ExecutionStatus,
NodeExecutionResult,
+ UserContext,
get_graph_execution,
)
from backend.data.model import _BaseCredentials
@@ -138,6 +139,7 @@ async def execute_block_test(block: Block):
"graph_exec_id": str(uuid.uuid4()),
"node_exec_id": str(uuid.uuid4()),
"user_id": str(uuid.uuid4()),
+ "user_context": UserContext(timezone="UTC"), # Default for tests
}
input_model = cast(type[BlockSchema], block.input_schema)
credentials_input_fields = input_model.get_credentials_fields()
diff --git a/autogpt_platform/backend/backend/util/timezone_name.py b/autogpt_platform/backend/backend/util/timezone_name.py
new file mode 100644
index 0000000000..1bd4caf196
--- /dev/null
+++ b/autogpt_platform/backend/backend/util/timezone_name.py
@@ -0,0 +1,115 @@
+"""
+Time zone name validation and serialization.
+
+This file is adapted from pydantic-extra-types:
+https://github.com/pydantic/pydantic-extra-types/blob/main/pydantic_extra_types/timezone_name.py
+
+The MIT License (MIT)
+
+Copyright (c) 2023 Samuel Colvin and other contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+Modifications:
+- Modified to always use pytz for timezone data to ensure consistency across environments
+- Removed zoneinfo support to prevent environment-specific timezone lists
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable, cast
+
+import pytz
+from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
+from pydantic_core import PydanticCustomError, core_schema
+
+# Cache the timezones at module level to avoid repeated computation
+ALL_TIMEZONES: set[str] = set(pytz.all_timezones)
+
+
+def get_timezones() -> set[str]:
+ """Get timezones from pytz for consistency across all environments."""
+ # Return cached timezone set
+ return ALL_TIMEZONES
+
+
+class TimeZoneNameSettings(type):
+ def __new__(
+ cls, name: str, bases: tuple[type, ...], dct: dict[str, Any], **kwargs: Any
+ ) -> type[TimeZoneName]:
+ dct["strict"] = kwargs.pop("strict", True)
+ return cast("type[TimeZoneName]", super().__new__(cls, name, bases, dct))
+
+ def __init__(
+ cls, name: str, bases: tuple[type, ...], dct: dict[str, Any], **kwargs: Any
+ ) -> None:
+ super().__init__(name, bases, dct)
+ cls.strict = kwargs.get("strict", True)
+
+
+def timezone_name_settings(
+ **kwargs: Any,
+) -> Callable[[type[TimeZoneName]], type[TimeZoneName]]:
+ def wrapper(cls: type[TimeZoneName]) -> type[TimeZoneName]:
+ cls.strict = kwargs.get("strict", True)
+ return cls
+
+ return wrapper
+
+
+@timezone_name_settings(strict=True)
+class TimeZoneName(str):
+ """TimeZoneName is a custom string subclass for validating and serializing timezone names."""
+
+ __slots__: list[str] = []
+ allowed_values: set[str] = set(get_timezones())
+ allowed_values_list: list[str] = sorted(allowed_values)
+ allowed_values_upper_to_correct: dict[str, str] = {
+ val.upper(): val for val in allowed_values
+ }
+ strict: bool
+
+ @classmethod
+ def _validate(
+ cls, __input_value: str, _: core_schema.ValidationInfo
+ ) -> TimeZoneName:
+ if __input_value not in cls.allowed_values:
+ if not cls.strict:
+ upper_value = __input_value.strip().upper()
+ if upper_value in cls.allowed_values_upper_to_correct:
+ return cls(cls.allowed_values_upper_to_correct[upper_value])
+ raise PydanticCustomError("TimeZoneName", "Invalid timezone name.")
+ return cls(__input_value)
+
+ @classmethod
+ def __get_pydantic_core_schema__(
+ cls, _: type[Any], __: GetCoreSchemaHandler
+ ) -> core_schema.AfterValidatorFunctionSchema:
+ return core_schema.with_info_after_validator_function(
+ cls._validate,
+ core_schema.str_schema(min_length=1),
+ )
+
+ @classmethod
+ def __get_pydantic_json_schema__(
+ cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
+ ) -> dict[str, Any]:
+ json_schema = handler(schema)
+ json_schema.update({"enum": cls.allowed_values_list})
+ return json_schema
diff --git a/autogpt_platform/backend/backend/util/timezone_utils.py b/autogpt_platform/backend/backend/util/timezone_utils.py
new file mode 100644
index 0000000000..6a6c438085
--- /dev/null
+++ b/autogpt_platform/backend/backend/util/timezone_utils.py
@@ -0,0 +1,148 @@
+"""
+Timezone conversion utilities for API endpoints.
+Handles conversion between user timezones and UTC for scheduler operations.
+"""
+
+import logging
+from datetime import datetime
+from typing import Optional
+from zoneinfo import ZoneInfo
+
+from croniter import croniter
+
+logger = logging.getLogger(__name__)
+
+
+def convert_cron_to_utc(cron_expr: str, user_timezone: str) -> str:
+ """
+ Convert a cron expression from user timezone to UTC.
+
+ NOTE: This is a simplified conversion that only adjusts minute and hour fields.
+ Complex cron expressions with specific day/month/weekday patterns may not
+ convert accurately due to timezone offset variations throughout the year.
+
+ Args:
+ cron_expr: Cron expression in user timezone
+ user_timezone: User's IANA timezone identifier
+
+ Returns:
+ Cron expression adjusted for UTC execution
+
+ Raises:
+ ValueError: If timezone or cron expression is invalid
+ """
+ try:
+ user_tz = ZoneInfo(user_timezone)
+ utc_tz = ZoneInfo("UTC")
+
+ # Split the cron expression into its five fields
+ cron_fields = cron_expr.strip().split()
+ if len(cron_fields) != 5:
+ raise ValueError(
+ "Cron expression must have 5 fields (minute hour day month weekday)"
+ )
+
+ # Get the current time in the user's timezone
+ now_user = datetime.now(user_tz)
+
+ # Get the next scheduled time in user timezone
+ cron = croniter(cron_expr, now_user)
+ next_user_time = cron.get_next(datetime)
+
+ # Convert to UTC
+ next_utc_time = next_user_time.astimezone(utc_tz)
+
+ # Adjust minute and hour fields for UTC, keep day/month/weekday as in original
+ utc_cron_parts = [
+ str(next_utc_time.minute),
+ str(next_utc_time.hour),
+ cron_fields[2], # day of month
+ cron_fields[3], # month
+ cron_fields[4], # day of week
+ ]
+
+ utc_cron = " ".join(utc_cron_parts)
+
+ logger.debug(
+ f"Converted cron '{cron_expr}' from {user_timezone} to UTC: '{utc_cron}'"
+ )
+ return utc_cron
+
+ except Exception as e:
+ logger.error(
+ f"Failed to convert cron expression '{cron_expr}' from {user_timezone} to UTC: {e}"
+ )
+ raise ValueError(f"Invalid cron expression or timezone: {e}")
+
+
+def convert_utc_time_to_user_timezone(utc_time_str: str, user_timezone: str) -> str:
+ """
+ Convert a UTC datetime string to user timezone.
+
+ Args:
+ utc_time_str: ISO format datetime string in UTC
+ user_timezone: User's IANA timezone identifier
+
+ Returns:
+ ISO format datetime string in user timezone
+ """
+ try:
+ # Parse the time string
+ parsed_time = datetime.fromisoformat(utc_time_str.replace("Z", "+00:00"))
+
+ user_tz = ZoneInfo(user_timezone)
+
+ # If the time already has timezone info, convert it to user timezone
+ if parsed_time.tzinfo is not None:
+ # Convert to user timezone regardless of source timezone
+ user_time = parsed_time.astimezone(user_tz)
+ return user_time.isoformat()
+
+ # If no timezone info, treat as UTC and convert to user timezone
+ parsed_time = parsed_time.replace(tzinfo=ZoneInfo("UTC"))
+ user_time = parsed_time.astimezone(user_tz)
+ return user_time.isoformat()
+
+ except Exception as e:
+ logger.error(
+ f"Failed to convert UTC time '{utc_time_str}' to {user_timezone}: {e}"
+ )
+ # Return original time if conversion fails
+ return utc_time_str
+
+
+def validate_timezone(timezone: str) -> bool:
+ """
+ Validate if a timezone string is a valid IANA timezone identifier.
+
+ Args:
+ timezone: Timezone string to validate
+
+ Returns:
+ True if valid, False otherwise
+ """
+ try:
+ ZoneInfo(timezone)
+ return True
+ except Exception:
+ return False
+
+
+def get_user_timezone_or_utc(user_timezone: Optional[str]) -> str:
+ """
+ Get user timezone or default to UTC if invalid/missing.
+
+ Args:
+ user_timezone: User's timezone preference
+
+ Returns:
+ Valid timezone string (user's preference or UTC fallback)
+ """
+ if not user_timezone or user_timezone == "not-set":
+ return "UTC"
+
+ if validate_timezone(user_timezone):
+ return user_timezone
+
+ logger.warning(f"Invalid user timezone '{user_timezone}', falling back to UTC")
+ return "UTC"
diff --git a/autogpt_platform/backend/migrations/20250819163527_add_user_timezone/migration.sql b/autogpt_platform/backend/migrations/20250819163527_add_user_timezone/migration.sql
new file mode 100644
index 0000000000..87bbed7dfb
--- /dev/null
+++ b/autogpt_platform/backend/migrations/20250819163527_add_user_timezone/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'not-set'
+ CHECK (timezone = 'not-set' OR now() AT TIME ZONE timezone IS NOT NULL);
diff --git a/autogpt_platform/backend/poetry.lock b/autogpt_platform/backend/poetry.lock
index 523caca0d8..d71b5e71f1 100644
--- a/autogpt_platform/backend/poetry.lock
+++ b/autogpt_platform/backend/poetry.lock
@@ -862,6 +862,22 @@ files = [
{file = "crashtest-0.4.1.tar.gz", hash = "sha256:80d7b1f316ebfbd429f648076d6275c877ba30ba48979de4191714a75266f0ce"},
]
+[[package]]
+name = "croniter"
+version = "6.0.0"
+description = "croniter provides iteration for datetime object with cron like format"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.6"
+groups = ["main"]
+files = [
+ {file = "croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368"},
+ {file = "croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577"},
+]
+
+[package.dependencies]
+python-dateutil = "*"
+pytz = ">2021.1"
+
[[package]]
name = "cryptography"
version = "43.0.3"
@@ -6806,4 +6822,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.14"
-content-hash = "f5120d066e4675f997a725289c8a5b288f38d5f7e8830049df4e87ab7c556641"
+content-hash = "e780199a6b02f5fef3f930a4f1d69443af1977b591172c3a18a299166345c37a"
diff --git a/autogpt_platform/backend/pyproject.toml b/autogpt_platform/backend/pyproject.toml
index ce074e5b3b..88e951389d 100644
--- a/autogpt_platform/backend/pyproject.toml
+++ b/autogpt_platform/backend/pyproject.toml
@@ -77,6 +77,7 @@ gcloud-aio-storage = "^9.5.0"
pandas = "^2.3.1"
firecrawl-py = "^2.16.3"
exa-py = "^1.14.20"
+croniter = "^6.0.0"
[tool.poetry.group.dev.dependencies]
aiohappyeyeballs = "^2.6.1"
diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma
index a859919719..13c3d2c529 100644
--- a/autogpt_platform/backend/schema.prisma
+++ b/autogpt_platform/backend/schema.prisma
@@ -36,6 +36,8 @@ model User {
notifyOnAgentApproved Boolean @default(true)
notifyOnAgentRejected Boolean @default(true)
+ timezone String @default("not-set")
+
// Relations
AgentGraphs AgentGraph[]
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-schedule-details-view.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-schedule-details-view.tsx
index dafbb33035..3ce4e455ad 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-schedule-details-view.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-schedule-details-view.tsx
@@ -18,6 +18,8 @@ import { Input } from "@/components/ui/input";
import LoadingBox from "@/components/ui/loading";
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
+import { formatScheduleTime } from "@/lib/timezone-utils";
+import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
import { PlayIcon } from "lucide-react";
export function AgentScheduleDetailsView({
@@ -39,6 +41,10 @@ export function AgentScheduleDetailsView({
const toastOnFail = useToastOnFail();
+ // Get user's timezone for displaying schedule times
+ const { data: timezoneData } = useGetV1GetUserTimezone();
+ const userTimezone = timezoneData?.data?.timezone || "UTC";
+
const infoStats: { label: string; value: React.ReactNode }[] = useMemo(() => {
return [
{
@@ -49,14 +55,14 @@ export function AgentScheduleDetailsView({
},
{
label: "Schedule",
- value: humanizeCronExpression(schedule.cron),
+ value: humanizeCronExpression(schedule.cron, userTimezone),
},
{
label: "Next run",
- value: schedule.next_run_time.toLocaleString(),
+ value: formatScheduleTime(schedule.next_run_time, userTimezone),
},
];
- }, [schedule, selectedRunStatus]);
+ }, [schedule, selectedRunStatus, userTimezone]);
const agentRunInputs: Record<
string,
diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/SettingsForm.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/SettingsForm.tsx
index 861d82cd77..89f4e46834 100644
--- a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/SettingsForm.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/SettingsForm.tsx
@@ -5,17 +5,25 @@ import { NotificationPreference } from "@/app/api/__generated__/models/notificat
import { User } from "@supabase/supabase-js";
import { EmailForm } from "./components/EmailForm/EmailForm";
import { NotificationForm } from "./components/NotificationForm/NotificationForm";
+import { TimezoneForm } from "./components/TimezoneForm/TimezoneForm";
type SettingsFormProps = {
preferences: NotificationPreference;
user: User;
+ timezone?: string;
};
-export function SettingsForm({ preferences, user }: SettingsFormProps) {
+export function SettingsForm({
+ preferences,
+ user,
+ timezone,
+}: SettingsFormProps) {
return (
+
+
);
diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/TimezoneForm/TimezoneForm.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/TimezoneForm/TimezoneForm.tsx
new file mode 100644
index 0000000000..c04955c9f3
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/TimezoneForm/TimezoneForm.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import * as React from "react";
+import { useTimezoneForm } from "./useTimezoneForm";
+import { User } from "@supabase/supabase-js";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/atoms/Button/Button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+
+type TimezoneFormProps = {
+ user: User;
+ currentTimezone?: string;
+};
+
+// Common timezones list - can be expanded later
+const TIMEZONES = [
+ { value: "UTC", label: "UTC (Coordinated Universal Time)" },
+ { value: "America/New_York", label: "Eastern Time (US & Canada)" },
+ { value: "America/Chicago", label: "Central Time (US & Canada)" },
+ { value: "America/Denver", label: "Mountain Time (US & Canada)" },
+ { value: "America/Los_Angeles", label: "Pacific Time (US & Canada)" },
+ { value: "America/Phoenix", label: "Arizona (US)" },
+ { value: "America/Anchorage", label: "Alaska (US)" },
+ { value: "Pacific/Honolulu", label: "Hawaii (US)" },
+ { value: "Europe/London", label: "London (UK)" },
+ { value: "Europe/Paris", label: "Paris (France)" },
+ { value: "Europe/Berlin", label: "Berlin (Germany)" },
+ { value: "Europe/Moscow", label: "Moscow (Russia)" },
+ { value: "Asia/Dubai", label: "Dubai (UAE)" },
+ { value: "Asia/Kolkata", label: "India Standard Time" },
+ { value: "Asia/Shanghai", label: "China Standard Time" },
+ { value: "Asia/Tokyo", label: "Tokyo (Japan)" },
+ { value: "Asia/Seoul", label: "Seoul (South Korea)" },
+ { value: "Asia/Singapore", label: "Singapore" },
+ { value: "Australia/Sydney", label: "Sydney (Australia)" },
+ { value: "Australia/Melbourne", label: "Melbourne (Australia)" },
+ { value: "Pacific/Auckland", label: "Auckland (New Zealand)" },
+ { value: "America/Toronto", label: "Toronto (Canada)" },
+ { value: "America/Vancouver", label: "Vancouver (Canada)" },
+ { value: "America/Mexico_City", label: "Mexico City (Mexico)" },
+ { value: "America/Sao_Paulo", label: "São Paulo (Brazil)" },
+ { value: "America/Buenos_Aires", label: "Buenos Aires (Argentina)" },
+ { value: "Africa/Cairo", label: "Cairo (Egypt)" },
+ { value: "Africa/Johannesburg", label: "Johannesburg (South Africa)" },
+];
+
+export function TimezoneForm({
+ user,
+ currentTimezone = "not-set",
+}: TimezoneFormProps) {
+ // If timezone is not set, try to detect it from the browser
+ const effectiveTimezone = React.useMemo(() => {
+ if (currentTimezone === "not-set") {
+ // Try to get browser timezone as a suggestion
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
+ } catch {
+ return "UTC";
+ }
+ }
+ return currentTimezone;
+ }, [currentTimezone]);
+
+ const { form, onSubmit, isLoading } = useTimezoneForm({
+ user,
+ currentTimezone: effectiveTimezone,
+ });
+
+ return (
+
+
+ Timezone
+
+
+
+
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/TimezoneForm/useTimezoneForm.ts b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/TimezoneForm/useTimezoneForm.ts
new file mode 100644
index 0000000000..0089a60260
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/components/SettingsForm/components/TimezoneForm/useTimezoneForm.ts
@@ -0,0 +1,73 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useToast } from "@/components/molecules/Toast/use-toast";
+import { User } from "@supabase/supabase-js";
+import {
+ usePostV1UpdateUserTimezone,
+ getGetV1GetUserTimezoneQueryKey,
+} from "@/app/api/__generated__/endpoints/auth/auth";
+import { useQueryClient } from "@tanstack/react-query";
+
+const formSchema = z.object({
+ timezone: z.string().min(1, "Please select a timezone"),
+});
+
+type FormData = z.infer;
+
+type UseTimezoneFormProps = {
+ user: User;
+ currentTimezone: string;
+};
+
+export const useTimezoneForm = ({ currentTimezone }: UseTimezoneFormProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ timezone: currentTimezone,
+ },
+ });
+
+ const updateTimezone = usePostV1UpdateUserTimezone();
+
+ const onSubmit = async (data: FormData) => {
+ setIsLoading(true);
+ try {
+ await updateTimezone.mutateAsync({
+ data: { timezone: data.timezone } as any,
+ });
+
+ // Invalidate the timezone query to refetch the updated value
+ await queryClient.invalidateQueries({
+ queryKey: getGetV1GetUserTimezoneQueryKey(),
+ });
+
+ toast({
+ title: "Success",
+ description: "Your timezone has been updated successfully.",
+ variant: "success",
+ });
+ } catch {
+ toast({
+ title: "Error",
+ description: "Failed to update timezone. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return {
+ form,
+ onSubmit,
+ isLoading,
+ };
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/page.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/page.tsx
index b97e72b40f..b738e08a34 100644
--- a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/page.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/settings/page.tsx
@@ -1,7 +1,11 @@
"use client";
-import { useGetV1GetNotificationPreferences } from "@/app/api/__generated__/endpoints/auth/auth";
+import {
+ useGetV1GetNotificationPreferences,
+ useGetV1GetUserTimezone,
+} from "@/app/api/__generated__/endpoints/auth/auth";
import { SettingsForm } from "@/app/(platform)/profile/(user)/settings/components/SettingsForm/SettingsForm";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
+import { useTimezoneDetection } from "@/hooks/useTimezoneDetection";
import * as React from "react";
import SettingsLoading from "./loading";
import { redirect } from "next/navigation";
@@ -10,8 +14,8 @@ import { Text } from "@/components/atoms/Text/Text";
export default function SettingsPage() {
const {
data: preferences,
- isError,
- isLoading,
+ isError: preferencesError,
+ isLoading: preferencesLoading,
} = useGetV1GetNotificationPreferences({
query: {
select: (res) => {
@@ -20,9 +24,24 @@ export default function SettingsPage() {
},
});
+ const { data: timezoneData, isLoading: timezoneLoading } =
+ useGetV1GetUserTimezone({
+ query: {
+ select: (res) => {
+ return res.data;
+ },
+ },
+ });
+
const { user, isUserLoading } = useSupabase();
- if (isLoading || isUserLoading) {
+ // Auto-detect timezone if it's not set
+ const timezone = timezoneData?.timezone
+ ? String(timezoneData.timezone)
+ : "not-set";
+ useTimezoneDetection(timezone);
+
+ if (preferencesLoading || isUserLoading || timezoneLoading) {
return ;
}
@@ -30,7 +49,7 @@ export default function SettingsPage() {
redirect("/login");
}
- if (isError || !preferences || !preferences.preferences) {
+ if (preferencesError || !preferences || !preferences.preferences) {
return "Errror..."; // TODO: Will use a Error reusable components from Block Menu redesign
}
@@ -42,7 +61,7 @@ export default function SettingsPage() {
Manage your account settings and preferences.
-
+
);
}
diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json
index 3a072366ea..8cfcabf6e9 100644
--- a/autogpt_platform/frontend/src/app/api/openapi.json
+++ b/autogpt_platform/frontend/src/app/api/openapi.json
@@ -651,6 +651,56 @@
}
}
},
+ "/api/auth/user/timezone": {
+ "get": {
+ "tags": ["v1", "auth"],
+ "summary": "Get user timezone",
+ "description": "Get user timezone setting.",
+ "operationId": "getV1Get user timezone",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/TimezoneResponse" }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": ["v1", "auth"],
+ "summary": "Update user timezone",
+ "description": "Update user timezone. The timezone should be a valid IANA timezone identifier.",
+ "operationId": "postV1Update user timezone",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/UpdateTimezoneRequest" }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/TimezoneResponse" }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/HTTPValidationError" }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/auth/user/preferences": {
"get": {
"tags": ["v1", "auth"],
@@ -6508,6 +6558,622 @@
"enum": ["DRAFT", "PENDING", "APPROVED", "REJECTED"],
"title": "SubmissionStatus"
},
+ "TimezoneResponse": {
+ "properties": {
+ "timezone": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "Africa/Abidjan",
+ "Africa/Accra",
+ "Africa/Addis_Ababa",
+ "Africa/Algiers",
+ "Africa/Asmara",
+ "Africa/Asmera",
+ "Africa/Bamako",
+ "Africa/Bangui",
+ "Africa/Banjul",
+ "Africa/Bissau",
+ "Africa/Blantyre",
+ "Africa/Brazzaville",
+ "Africa/Bujumbura",
+ "Africa/Cairo",
+ "Africa/Casablanca",
+ "Africa/Ceuta",
+ "Africa/Conakry",
+ "Africa/Dakar",
+ "Africa/Dar_es_Salaam",
+ "Africa/Djibouti",
+ "Africa/Douala",
+ "Africa/El_Aaiun",
+ "Africa/Freetown",
+ "Africa/Gaborone",
+ "Africa/Harare",
+ "Africa/Johannesburg",
+ "Africa/Juba",
+ "Africa/Kampala",
+ "Africa/Khartoum",
+ "Africa/Kigali",
+ "Africa/Kinshasa",
+ "Africa/Lagos",
+ "Africa/Libreville",
+ "Africa/Lome",
+ "Africa/Luanda",
+ "Africa/Lubumbashi",
+ "Africa/Lusaka",
+ "Africa/Malabo",
+ "Africa/Maputo",
+ "Africa/Maseru",
+ "Africa/Mbabane",
+ "Africa/Mogadishu",
+ "Africa/Monrovia",
+ "Africa/Nairobi",
+ "Africa/Ndjamena",
+ "Africa/Niamey",
+ "Africa/Nouakchott",
+ "Africa/Ouagadougou",
+ "Africa/Porto-Novo",
+ "Africa/Sao_Tome",
+ "Africa/Timbuktu",
+ "Africa/Tripoli",
+ "Africa/Tunis",
+ "Africa/Windhoek",
+ "America/Adak",
+ "America/Anchorage",
+ "America/Anguilla",
+ "America/Antigua",
+ "America/Araguaina",
+ "America/Argentina/Buenos_Aires",
+ "America/Argentina/Catamarca",
+ "America/Argentina/ComodRivadavia",
+ "America/Argentina/Cordoba",
+ "America/Argentina/Jujuy",
+ "America/Argentina/La_Rioja",
+ "America/Argentina/Mendoza",
+ "America/Argentina/Rio_Gallegos",
+ "America/Argentina/Salta",
+ "America/Argentina/San_Juan",
+ "America/Argentina/San_Luis",
+ "America/Argentina/Tucuman",
+ "America/Argentina/Ushuaia",
+ "America/Aruba",
+ "America/Asuncion",
+ "America/Atikokan",
+ "America/Atka",
+ "America/Bahia",
+ "America/Bahia_Banderas",
+ "America/Barbados",
+ "America/Belem",
+ "America/Belize",
+ "America/Blanc-Sablon",
+ "America/Boa_Vista",
+ "America/Bogota",
+ "America/Boise",
+ "America/Buenos_Aires",
+ "America/Cambridge_Bay",
+ "America/Campo_Grande",
+ "America/Cancun",
+ "America/Caracas",
+ "America/Catamarca",
+ "America/Cayenne",
+ "America/Cayman",
+ "America/Chicago",
+ "America/Chihuahua",
+ "America/Ciudad_Juarez",
+ "America/Coral_Harbour",
+ "America/Cordoba",
+ "America/Costa_Rica",
+ "America/Coyhaique",
+ "America/Creston",
+ "America/Cuiaba",
+ "America/Curacao",
+ "America/Danmarkshavn",
+ "America/Dawson",
+ "America/Dawson_Creek",
+ "America/Denver",
+ "America/Detroit",
+ "America/Dominica",
+ "America/Edmonton",
+ "America/Eirunepe",
+ "America/El_Salvador",
+ "America/Ensenada",
+ "America/Fort_Nelson",
+ "America/Fort_Wayne",
+ "America/Fortaleza",
+ "America/Glace_Bay",
+ "America/Godthab",
+ "America/Goose_Bay",
+ "America/Grand_Turk",
+ "America/Grenada",
+ "America/Guadeloupe",
+ "America/Guatemala",
+ "America/Guayaquil",
+ "America/Guyana",
+ "America/Halifax",
+ "America/Havana",
+ "America/Hermosillo",
+ "America/Indiana/Indianapolis",
+ "America/Indiana/Knox",
+ "America/Indiana/Marengo",
+ "America/Indiana/Petersburg",
+ "America/Indiana/Tell_City",
+ "America/Indiana/Vevay",
+ "America/Indiana/Vincennes",
+ "America/Indiana/Winamac",
+ "America/Indianapolis",
+ "America/Inuvik",
+ "America/Iqaluit",
+ "America/Jamaica",
+ "America/Jujuy",
+ "America/Juneau",
+ "America/Kentucky/Louisville",
+ "America/Kentucky/Monticello",
+ "America/Knox_IN",
+ "America/Kralendijk",
+ "America/La_Paz",
+ "America/Lima",
+ "America/Los_Angeles",
+ "America/Louisville",
+ "America/Lower_Princes",
+ "America/Maceio",
+ "America/Managua",
+ "America/Manaus",
+ "America/Marigot",
+ "America/Martinique",
+ "America/Matamoros",
+ "America/Mazatlan",
+ "America/Mendoza",
+ "America/Menominee",
+ "America/Merida",
+ "America/Metlakatla",
+ "America/Mexico_City",
+ "America/Miquelon",
+ "America/Moncton",
+ "America/Monterrey",
+ "America/Montevideo",
+ "America/Montreal",
+ "America/Montserrat",
+ "America/Nassau",
+ "America/New_York",
+ "America/Nipigon",
+ "America/Nome",
+ "America/Noronha",
+ "America/North_Dakota/Beulah",
+ "America/North_Dakota/Center",
+ "America/North_Dakota/New_Salem",
+ "America/Nuuk",
+ "America/Ojinaga",
+ "America/Panama",
+ "America/Pangnirtung",
+ "America/Paramaribo",
+ "America/Phoenix",
+ "America/Port-au-Prince",
+ "America/Port_of_Spain",
+ "America/Porto_Acre",
+ "America/Porto_Velho",
+ "America/Puerto_Rico",
+ "America/Punta_Arenas",
+ "America/Rainy_River",
+ "America/Rankin_Inlet",
+ "America/Recife",
+ "America/Regina",
+ "America/Resolute",
+ "America/Rio_Branco",
+ "America/Rosario",
+ "America/Santa_Isabel",
+ "America/Santarem",
+ "America/Santiago",
+ "America/Santo_Domingo",
+ "America/Sao_Paulo",
+ "America/Scoresbysund",
+ "America/Shiprock",
+ "America/Sitka",
+ "America/St_Barthelemy",
+ "America/St_Johns",
+ "America/St_Kitts",
+ "America/St_Lucia",
+ "America/St_Thomas",
+ "America/St_Vincent",
+ "America/Swift_Current",
+ "America/Tegucigalpa",
+ "America/Thule",
+ "America/Thunder_Bay",
+ "America/Tijuana",
+ "America/Toronto",
+ "America/Tortola",
+ "America/Vancouver",
+ "America/Virgin",
+ "America/Whitehorse",
+ "America/Winnipeg",
+ "America/Yakutat",
+ "America/Yellowknife",
+ "Antarctica/Casey",
+ "Antarctica/Davis",
+ "Antarctica/DumontDUrville",
+ "Antarctica/Macquarie",
+ "Antarctica/Mawson",
+ "Antarctica/McMurdo",
+ "Antarctica/Palmer",
+ "Antarctica/Rothera",
+ "Antarctica/South_Pole",
+ "Antarctica/Syowa",
+ "Antarctica/Troll",
+ "Antarctica/Vostok",
+ "Arctic/Longyearbyen",
+ "Asia/Aden",
+ "Asia/Almaty",
+ "Asia/Amman",
+ "Asia/Anadyr",
+ "Asia/Aqtau",
+ "Asia/Aqtobe",
+ "Asia/Ashgabat",
+ "Asia/Ashkhabad",
+ "Asia/Atyrau",
+ "Asia/Baghdad",
+ "Asia/Bahrain",
+ "Asia/Baku",
+ "Asia/Bangkok",
+ "Asia/Barnaul",
+ "Asia/Beirut",
+ "Asia/Bishkek",
+ "Asia/Brunei",
+ "Asia/Calcutta",
+ "Asia/Chita",
+ "Asia/Choibalsan",
+ "Asia/Chongqing",
+ "Asia/Chungking",
+ "Asia/Colombo",
+ "Asia/Dacca",
+ "Asia/Damascus",
+ "Asia/Dhaka",
+ "Asia/Dili",
+ "Asia/Dubai",
+ "Asia/Dushanbe",
+ "Asia/Famagusta",
+ "Asia/Gaza",
+ "Asia/Harbin",
+ "Asia/Hebron",
+ "Asia/Ho_Chi_Minh",
+ "Asia/Hong_Kong",
+ "Asia/Hovd",
+ "Asia/Irkutsk",
+ "Asia/Istanbul",
+ "Asia/Jakarta",
+ "Asia/Jayapura",
+ "Asia/Jerusalem",
+ "Asia/Kabul",
+ "Asia/Kamchatka",
+ "Asia/Karachi",
+ "Asia/Kashgar",
+ "Asia/Kathmandu",
+ "Asia/Katmandu",
+ "Asia/Khandyga",
+ "Asia/Kolkata",
+ "Asia/Krasnoyarsk",
+ "Asia/Kuala_Lumpur",
+ "Asia/Kuching",
+ "Asia/Kuwait",
+ "Asia/Macao",
+ "Asia/Macau",
+ "Asia/Magadan",
+ "Asia/Makassar",
+ "Asia/Manila",
+ "Asia/Muscat",
+ "Asia/Nicosia",
+ "Asia/Novokuznetsk",
+ "Asia/Novosibirsk",
+ "Asia/Omsk",
+ "Asia/Oral",
+ "Asia/Phnom_Penh",
+ "Asia/Pontianak",
+ "Asia/Pyongyang",
+ "Asia/Qatar",
+ "Asia/Qostanay",
+ "Asia/Qyzylorda",
+ "Asia/Rangoon",
+ "Asia/Riyadh",
+ "Asia/Saigon",
+ "Asia/Sakhalin",
+ "Asia/Samarkand",
+ "Asia/Seoul",
+ "Asia/Shanghai",
+ "Asia/Singapore",
+ "Asia/Srednekolymsk",
+ "Asia/Taipei",
+ "Asia/Tashkent",
+ "Asia/Tbilisi",
+ "Asia/Tehran",
+ "Asia/Tel_Aviv",
+ "Asia/Thimbu",
+ "Asia/Thimphu",
+ "Asia/Tokyo",
+ "Asia/Tomsk",
+ "Asia/Ujung_Pandang",
+ "Asia/Ulaanbaatar",
+ "Asia/Ulan_Bator",
+ "Asia/Urumqi",
+ "Asia/Ust-Nera",
+ "Asia/Vientiane",
+ "Asia/Vladivostok",
+ "Asia/Yakutsk",
+ "Asia/Yangon",
+ "Asia/Yekaterinburg",
+ "Asia/Yerevan",
+ "Atlantic/Azores",
+ "Atlantic/Bermuda",
+ "Atlantic/Canary",
+ "Atlantic/Cape_Verde",
+ "Atlantic/Faeroe",
+ "Atlantic/Faroe",
+ "Atlantic/Jan_Mayen",
+ "Atlantic/Madeira",
+ "Atlantic/Reykjavik",
+ "Atlantic/South_Georgia",
+ "Atlantic/St_Helena",
+ "Atlantic/Stanley",
+ "Australia/ACT",
+ "Australia/Adelaide",
+ "Australia/Brisbane",
+ "Australia/Broken_Hill",
+ "Australia/Canberra",
+ "Australia/Currie",
+ "Australia/Darwin",
+ "Australia/Eucla",
+ "Australia/Hobart",
+ "Australia/LHI",
+ "Australia/Lindeman",
+ "Australia/Lord_Howe",
+ "Australia/Melbourne",
+ "Australia/NSW",
+ "Australia/North",
+ "Australia/Perth",
+ "Australia/Queensland",
+ "Australia/South",
+ "Australia/Sydney",
+ "Australia/Tasmania",
+ "Australia/Victoria",
+ "Australia/West",
+ "Australia/Yancowinna",
+ "Brazil/Acre",
+ "Brazil/DeNoronha",
+ "Brazil/East",
+ "Brazil/West",
+ "CET",
+ "CST6CDT",
+ "Canada/Atlantic",
+ "Canada/Central",
+ "Canada/Eastern",
+ "Canada/Mountain",
+ "Canada/Newfoundland",
+ "Canada/Pacific",
+ "Canada/Saskatchewan",
+ "Canada/Yukon",
+ "Chile/Continental",
+ "Chile/EasterIsland",
+ "Cuba",
+ "EET",
+ "EST",
+ "EST5EDT",
+ "Egypt",
+ "Eire",
+ "Etc/GMT",
+ "Etc/GMT+0",
+ "Etc/GMT+1",
+ "Etc/GMT+10",
+ "Etc/GMT+11",
+ "Etc/GMT+12",
+ "Etc/GMT+2",
+ "Etc/GMT+3",
+ "Etc/GMT+4",
+ "Etc/GMT+5",
+ "Etc/GMT+6",
+ "Etc/GMT+7",
+ "Etc/GMT+8",
+ "Etc/GMT+9",
+ "Etc/GMT-0",
+ "Etc/GMT-1",
+ "Etc/GMT-10",
+ "Etc/GMT-11",
+ "Etc/GMT-12",
+ "Etc/GMT-13",
+ "Etc/GMT-14",
+ "Etc/GMT-2",
+ "Etc/GMT-3",
+ "Etc/GMT-4",
+ "Etc/GMT-5",
+ "Etc/GMT-6",
+ "Etc/GMT-7",
+ "Etc/GMT-8",
+ "Etc/GMT-9",
+ "Etc/GMT0",
+ "Etc/Greenwich",
+ "Etc/UCT",
+ "Etc/UTC",
+ "Etc/Universal",
+ "Etc/Zulu",
+ "Europe/Amsterdam",
+ "Europe/Andorra",
+ "Europe/Astrakhan",
+ "Europe/Athens",
+ "Europe/Belfast",
+ "Europe/Belgrade",
+ "Europe/Berlin",
+ "Europe/Bratislava",
+ "Europe/Brussels",
+ "Europe/Bucharest",
+ "Europe/Budapest",
+ "Europe/Busingen",
+ "Europe/Chisinau",
+ "Europe/Copenhagen",
+ "Europe/Dublin",
+ "Europe/Gibraltar",
+ "Europe/Guernsey",
+ "Europe/Helsinki",
+ "Europe/Isle_of_Man",
+ "Europe/Istanbul",
+ "Europe/Jersey",
+ "Europe/Kaliningrad",
+ "Europe/Kiev",
+ "Europe/Kirov",
+ "Europe/Kyiv",
+ "Europe/Lisbon",
+ "Europe/Ljubljana",
+ "Europe/London",
+ "Europe/Luxembourg",
+ "Europe/Madrid",
+ "Europe/Malta",
+ "Europe/Mariehamn",
+ "Europe/Minsk",
+ "Europe/Monaco",
+ "Europe/Moscow",
+ "Europe/Nicosia",
+ "Europe/Oslo",
+ "Europe/Paris",
+ "Europe/Podgorica",
+ "Europe/Prague",
+ "Europe/Riga",
+ "Europe/Rome",
+ "Europe/Samara",
+ "Europe/San_Marino",
+ "Europe/Sarajevo",
+ "Europe/Saratov",
+ "Europe/Simferopol",
+ "Europe/Skopje",
+ "Europe/Sofia",
+ "Europe/Stockholm",
+ "Europe/Tallinn",
+ "Europe/Tirane",
+ "Europe/Tiraspol",
+ "Europe/Ulyanovsk",
+ "Europe/Uzhgorod",
+ "Europe/Vaduz",
+ "Europe/Vatican",
+ "Europe/Vienna",
+ "Europe/Vilnius",
+ "Europe/Volgograd",
+ "Europe/Warsaw",
+ "Europe/Zagreb",
+ "Europe/Zaporozhye",
+ "Europe/Zurich",
+ "GB",
+ "GB-Eire",
+ "GMT",
+ "GMT+0",
+ "GMT-0",
+ "GMT0",
+ "Greenwich",
+ "HST",
+ "Hongkong",
+ "Iceland",
+ "Indian/Antananarivo",
+ "Indian/Chagos",
+ "Indian/Christmas",
+ "Indian/Cocos",
+ "Indian/Comoro",
+ "Indian/Kerguelen",
+ "Indian/Mahe",
+ "Indian/Maldives",
+ "Indian/Mauritius",
+ "Indian/Mayotte",
+ "Indian/Reunion",
+ "Iran",
+ "Israel",
+ "Jamaica",
+ "Japan",
+ "Kwajalein",
+ "Libya",
+ "MET",
+ "MST",
+ "MST7MDT",
+ "Mexico/BajaNorte",
+ "Mexico/BajaSur",
+ "Mexico/General",
+ "NZ",
+ "NZ-CHAT",
+ "Navajo",
+ "PRC",
+ "PST8PDT",
+ "Pacific/Apia",
+ "Pacific/Auckland",
+ "Pacific/Bougainville",
+ "Pacific/Chatham",
+ "Pacific/Chuuk",
+ "Pacific/Easter",
+ "Pacific/Efate",
+ "Pacific/Enderbury",
+ "Pacific/Fakaofo",
+ "Pacific/Fiji",
+ "Pacific/Funafuti",
+ "Pacific/Galapagos",
+ "Pacific/Gambier",
+ "Pacific/Guadalcanal",
+ "Pacific/Guam",
+ "Pacific/Honolulu",
+ "Pacific/Johnston",
+ "Pacific/Kanton",
+ "Pacific/Kiritimati",
+ "Pacific/Kosrae",
+ "Pacific/Kwajalein",
+ "Pacific/Majuro",
+ "Pacific/Marquesas",
+ "Pacific/Midway",
+ "Pacific/Nauru",
+ "Pacific/Niue",
+ "Pacific/Norfolk",
+ "Pacific/Noumea",
+ "Pacific/Pago_Pago",
+ "Pacific/Palau",
+ "Pacific/Pitcairn",
+ "Pacific/Pohnpei",
+ "Pacific/Ponape",
+ "Pacific/Port_Moresby",
+ "Pacific/Rarotonga",
+ "Pacific/Saipan",
+ "Pacific/Samoa",
+ "Pacific/Tahiti",
+ "Pacific/Tarawa",
+ "Pacific/Tongatapu",
+ "Pacific/Truk",
+ "Pacific/Wake",
+ "Pacific/Wallis",
+ "Pacific/Yap",
+ "Poland",
+ "Portugal",
+ "ROC",
+ "ROK",
+ "Singapore",
+ "Turkey",
+ "UCT",
+ "US/Alaska",
+ "US/Aleutian",
+ "US/Arizona",
+ "US/Central",
+ "US/East-Indiana",
+ "US/Eastern",
+ "US/Hawaii",
+ "US/Indiana-Starke",
+ "US/Michigan",
+ "US/Mountain",
+ "US/Pacific",
+ "US/Samoa",
+ "UTC",
+ "Universal",
+ "W-SU",
+ "WET",
+ "Zulu"
+ ],
+ "minLength": 1
+ },
+ { "type": "string" }
+ ],
+ "title": "Timezone"
+ }
+ },
+ "type": "object",
+ "required": ["timezone"],
+ "title": "TimezoneResponse"
+ },
"TransactionHistory": {
"properties": {
"transactions": {
@@ -6617,6 +7283,617 @@
"required": ["permissions"],
"title": "UpdatePermissionsRequest"
},
+ "UpdateTimezoneRequest": {
+ "properties": {
+ "timezone": {
+ "type": "string",
+ "enum": [
+ "Africa/Abidjan",
+ "Africa/Accra",
+ "Africa/Addis_Ababa",
+ "Africa/Algiers",
+ "Africa/Asmara",
+ "Africa/Asmera",
+ "Africa/Bamako",
+ "Africa/Bangui",
+ "Africa/Banjul",
+ "Africa/Bissau",
+ "Africa/Blantyre",
+ "Africa/Brazzaville",
+ "Africa/Bujumbura",
+ "Africa/Cairo",
+ "Africa/Casablanca",
+ "Africa/Ceuta",
+ "Africa/Conakry",
+ "Africa/Dakar",
+ "Africa/Dar_es_Salaam",
+ "Africa/Djibouti",
+ "Africa/Douala",
+ "Africa/El_Aaiun",
+ "Africa/Freetown",
+ "Africa/Gaborone",
+ "Africa/Harare",
+ "Africa/Johannesburg",
+ "Africa/Juba",
+ "Africa/Kampala",
+ "Africa/Khartoum",
+ "Africa/Kigali",
+ "Africa/Kinshasa",
+ "Africa/Lagos",
+ "Africa/Libreville",
+ "Africa/Lome",
+ "Africa/Luanda",
+ "Africa/Lubumbashi",
+ "Africa/Lusaka",
+ "Africa/Malabo",
+ "Africa/Maputo",
+ "Africa/Maseru",
+ "Africa/Mbabane",
+ "Africa/Mogadishu",
+ "Africa/Monrovia",
+ "Africa/Nairobi",
+ "Africa/Ndjamena",
+ "Africa/Niamey",
+ "Africa/Nouakchott",
+ "Africa/Ouagadougou",
+ "Africa/Porto-Novo",
+ "Africa/Sao_Tome",
+ "Africa/Timbuktu",
+ "Africa/Tripoli",
+ "Africa/Tunis",
+ "Africa/Windhoek",
+ "America/Adak",
+ "America/Anchorage",
+ "America/Anguilla",
+ "America/Antigua",
+ "America/Araguaina",
+ "America/Argentina/Buenos_Aires",
+ "America/Argentina/Catamarca",
+ "America/Argentina/ComodRivadavia",
+ "America/Argentina/Cordoba",
+ "America/Argentina/Jujuy",
+ "America/Argentina/La_Rioja",
+ "America/Argentina/Mendoza",
+ "America/Argentina/Rio_Gallegos",
+ "America/Argentina/Salta",
+ "America/Argentina/San_Juan",
+ "America/Argentina/San_Luis",
+ "America/Argentina/Tucuman",
+ "America/Argentina/Ushuaia",
+ "America/Aruba",
+ "America/Asuncion",
+ "America/Atikokan",
+ "America/Atka",
+ "America/Bahia",
+ "America/Bahia_Banderas",
+ "America/Barbados",
+ "America/Belem",
+ "America/Belize",
+ "America/Blanc-Sablon",
+ "America/Boa_Vista",
+ "America/Bogota",
+ "America/Boise",
+ "America/Buenos_Aires",
+ "America/Cambridge_Bay",
+ "America/Campo_Grande",
+ "America/Cancun",
+ "America/Caracas",
+ "America/Catamarca",
+ "America/Cayenne",
+ "America/Cayman",
+ "America/Chicago",
+ "America/Chihuahua",
+ "America/Ciudad_Juarez",
+ "America/Coral_Harbour",
+ "America/Cordoba",
+ "America/Costa_Rica",
+ "America/Coyhaique",
+ "America/Creston",
+ "America/Cuiaba",
+ "America/Curacao",
+ "America/Danmarkshavn",
+ "America/Dawson",
+ "America/Dawson_Creek",
+ "America/Denver",
+ "America/Detroit",
+ "America/Dominica",
+ "America/Edmonton",
+ "America/Eirunepe",
+ "America/El_Salvador",
+ "America/Ensenada",
+ "America/Fort_Nelson",
+ "America/Fort_Wayne",
+ "America/Fortaleza",
+ "America/Glace_Bay",
+ "America/Godthab",
+ "America/Goose_Bay",
+ "America/Grand_Turk",
+ "America/Grenada",
+ "America/Guadeloupe",
+ "America/Guatemala",
+ "America/Guayaquil",
+ "America/Guyana",
+ "America/Halifax",
+ "America/Havana",
+ "America/Hermosillo",
+ "America/Indiana/Indianapolis",
+ "America/Indiana/Knox",
+ "America/Indiana/Marengo",
+ "America/Indiana/Petersburg",
+ "America/Indiana/Tell_City",
+ "America/Indiana/Vevay",
+ "America/Indiana/Vincennes",
+ "America/Indiana/Winamac",
+ "America/Indianapolis",
+ "America/Inuvik",
+ "America/Iqaluit",
+ "America/Jamaica",
+ "America/Jujuy",
+ "America/Juneau",
+ "America/Kentucky/Louisville",
+ "America/Kentucky/Monticello",
+ "America/Knox_IN",
+ "America/Kralendijk",
+ "America/La_Paz",
+ "America/Lima",
+ "America/Los_Angeles",
+ "America/Louisville",
+ "America/Lower_Princes",
+ "America/Maceio",
+ "America/Managua",
+ "America/Manaus",
+ "America/Marigot",
+ "America/Martinique",
+ "America/Matamoros",
+ "America/Mazatlan",
+ "America/Mendoza",
+ "America/Menominee",
+ "America/Merida",
+ "America/Metlakatla",
+ "America/Mexico_City",
+ "America/Miquelon",
+ "America/Moncton",
+ "America/Monterrey",
+ "America/Montevideo",
+ "America/Montreal",
+ "America/Montserrat",
+ "America/Nassau",
+ "America/New_York",
+ "America/Nipigon",
+ "America/Nome",
+ "America/Noronha",
+ "America/North_Dakota/Beulah",
+ "America/North_Dakota/Center",
+ "America/North_Dakota/New_Salem",
+ "America/Nuuk",
+ "America/Ojinaga",
+ "America/Panama",
+ "America/Pangnirtung",
+ "America/Paramaribo",
+ "America/Phoenix",
+ "America/Port-au-Prince",
+ "America/Port_of_Spain",
+ "America/Porto_Acre",
+ "America/Porto_Velho",
+ "America/Puerto_Rico",
+ "America/Punta_Arenas",
+ "America/Rainy_River",
+ "America/Rankin_Inlet",
+ "America/Recife",
+ "America/Regina",
+ "America/Resolute",
+ "America/Rio_Branco",
+ "America/Rosario",
+ "America/Santa_Isabel",
+ "America/Santarem",
+ "America/Santiago",
+ "America/Santo_Domingo",
+ "America/Sao_Paulo",
+ "America/Scoresbysund",
+ "America/Shiprock",
+ "America/Sitka",
+ "America/St_Barthelemy",
+ "America/St_Johns",
+ "America/St_Kitts",
+ "America/St_Lucia",
+ "America/St_Thomas",
+ "America/St_Vincent",
+ "America/Swift_Current",
+ "America/Tegucigalpa",
+ "America/Thule",
+ "America/Thunder_Bay",
+ "America/Tijuana",
+ "America/Toronto",
+ "America/Tortola",
+ "America/Vancouver",
+ "America/Virgin",
+ "America/Whitehorse",
+ "America/Winnipeg",
+ "America/Yakutat",
+ "America/Yellowknife",
+ "Antarctica/Casey",
+ "Antarctica/Davis",
+ "Antarctica/DumontDUrville",
+ "Antarctica/Macquarie",
+ "Antarctica/Mawson",
+ "Antarctica/McMurdo",
+ "Antarctica/Palmer",
+ "Antarctica/Rothera",
+ "Antarctica/South_Pole",
+ "Antarctica/Syowa",
+ "Antarctica/Troll",
+ "Antarctica/Vostok",
+ "Arctic/Longyearbyen",
+ "Asia/Aden",
+ "Asia/Almaty",
+ "Asia/Amman",
+ "Asia/Anadyr",
+ "Asia/Aqtau",
+ "Asia/Aqtobe",
+ "Asia/Ashgabat",
+ "Asia/Ashkhabad",
+ "Asia/Atyrau",
+ "Asia/Baghdad",
+ "Asia/Bahrain",
+ "Asia/Baku",
+ "Asia/Bangkok",
+ "Asia/Barnaul",
+ "Asia/Beirut",
+ "Asia/Bishkek",
+ "Asia/Brunei",
+ "Asia/Calcutta",
+ "Asia/Chita",
+ "Asia/Choibalsan",
+ "Asia/Chongqing",
+ "Asia/Chungking",
+ "Asia/Colombo",
+ "Asia/Dacca",
+ "Asia/Damascus",
+ "Asia/Dhaka",
+ "Asia/Dili",
+ "Asia/Dubai",
+ "Asia/Dushanbe",
+ "Asia/Famagusta",
+ "Asia/Gaza",
+ "Asia/Harbin",
+ "Asia/Hebron",
+ "Asia/Ho_Chi_Minh",
+ "Asia/Hong_Kong",
+ "Asia/Hovd",
+ "Asia/Irkutsk",
+ "Asia/Istanbul",
+ "Asia/Jakarta",
+ "Asia/Jayapura",
+ "Asia/Jerusalem",
+ "Asia/Kabul",
+ "Asia/Kamchatka",
+ "Asia/Karachi",
+ "Asia/Kashgar",
+ "Asia/Kathmandu",
+ "Asia/Katmandu",
+ "Asia/Khandyga",
+ "Asia/Kolkata",
+ "Asia/Krasnoyarsk",
+ "Asia/Kuala_Lumpur",
+ "Asia/Kuching",
+ "Asia/Kuwait",
+ "Asia/Macao",
+ "Asia/Macau",
+ "Asia/Magadan",
+ "Asia/Makassar",
+ "Asia/Manila",
+ "Asia/Muscat",
+ "Asia/Nicosia",
+ "Asia/Novokuznetsk",
+ "Asia/Novosibirsk",
+ "Asia/Omsk",
+ "Asia/Oral",
+ "Asia/Phnom_Penh",
+ "Asia/Pontianak",
+ "Asia/Pyongyang",
+ "Asia/Qatar",
+ "Asia/Qostanay",
+ "Asia/Qyzylorda",
+ "Asia/Rangoon",
+ "Asia/Riyadh",
+ "Asia/Saigon",
+ "Asia/Sakhalin",
+ "Asia/Samarkand",
+ "Asia/Seoul",
+ "Asia/Shanghai",
+ "Asia/Singapore",
+ "Asia/Srednekolymsk",
+ "Asia/Taipei",
+ "Asia/Tashkent",
+ "Asia/Tbilisi",
+ "Asia/Tehran",
+ "Asia/Tel_Aviv",
+ "Asia/Thimbu",
+ "Asia/Thimphu",
+ "Asia/Tokyo",
+ "Asia/Tomsk",
+ "Asia/Ujung_Pandang",
+ "Asia/Ulaanbaatar",
+ "Asia/Ulan_Bator",
+ "Asia/Urumqi",
+ "Asia/Ust-Nera",
+ "Asia/Vientiane",
+ "Asia/Vladivostok",
+ "Asia/Yakutsk",
+ "Asia/Yangon",
+ "Asia/Yekaterinburg",
+ "Asia/Yerevan",
+ "Atlantic/Azores",
+ "Atlantic/Bermuda",
+ "Atlantic/Canary",
+ "Atlantic/Cape_Verde",
+ "Atlantic/Faeroe",
+ "Atlantic/Faroe",
+ "Atlantic/Jan_Mayen",
+ "Atlantic/Madeira",
+ "Atlantic/Reykjavik",
+ "Atlantic/South_Georgia",
+ "Atlantic/St_Helena",
+ "Atlantic/Stanley",
+ "Australia/ACT",
+ "Australia/Adelaide",
+ "Australia/Brisbane",
+ "Australia/Broken_Hill",
+ "Australia/Canberra",
+ "Australia/Currie",
+ "Australia/Darwin",
+ "Australia/Eucla",
+ "Australia/Hobart",
+ "Australia/LHI",
+ "Australia/Lindeman",
+ "Australia/Lord_Howe",
+ "Australia/Melbourne",
+ "Australia/NSW",
+ "Australia/North",
+ "Australia/Perth",
+ "Australia/Queensland",
+ "Australia/South",
+ "Australia/Sydney",
+ "Australia/Tasmania",
+ "Australia/Victoria",
+ "Australia/West",
+ "Australia/Yancowinna",
+ "Brazil/Acre",
+ "Brazil/DeNoronha",
+ "Brazil/East",
+ "Brazil/West",
+ "CET",
+ "CST6CDT",
+ "Canada/Atlantic",
+ "Canada/Central",
+ "Canada/Eastern",
+ "Canada/Mountain",
+ "Canada/Newfoundland",
+ "Canada/Pacific",
+ "Canada/Saskatchewan",
+ "Canada/Yukon",
+ "Chile/Continental",
+ "Chile/EasterIsland",
+ "Cuba",
+ "EET",
+ "EST",
+ "EST5EDT",
+ "Egypt",
+ "Eire",
+ "Etc/GMT",
+ "Etc/GMT+0",
+ "Etc/GMT+1",
+ "Etc/GMT+10",
+ "Etc/GMT+11",
+ "Etc/GMT+12",
+ "Etc/GMT+2",
+ "Etc/GMT+3",
+ "Etc/GMT+4",
+ "Etc/GMT+5",
+ "Etc/GMT+6",
+ "Etc/GMT+7",
+ "Etc/GMT+8",
+ "Etc/GMT+9",
+ "Etc/GMT-0",
+ "Etc/GMT-1",
+ "Etc/GMT-10",
+ "Etc/GMT-11",
+ "Etc/GMT-12",
+ "Etc/GMT-13",
+ "Etc/GMT-14",
+ "Etc/GMT-2",
+ "Etc/GMT-3",
+ "Etc/GMT-4",
+ "Etc/GMT-5",
+ "Etc/GMT-6",
+ "Etc/GMT-7",
+ "Etc/GMT-8",
+ "Etc/GMT-9",
+ "Etc/GMT0",
+ "Etc/Greenwich",
+ "Etc/UCT",
+ "Etc/UTC",
+ "Etc/Universal",
+ "Etc/Zulu",
+ "Europe/Amsterdam",
+ "Europe/Andorra",
+ "Europe/Astrakhan",
+ "Europe/Athens",
+ "Europe/Belfast",
+ "Europe/Belgrade",
+ "Europe/Berlin",
+ "Europe/Bratislava",
+ "Europe/Brussels",
+ "Europe/Bucharest",
+ "Europe/Budapest",
+ "Europe/Busingen",
+ "Europe/Chisinau",
+ "Europe/Copenhagen",
+ "Europe/Dublin",
+ "Europe/Gibraltar",
+ "Europe/Guernsey",
+ "Europe/Helsinki",
+ "Europe/Isle_of_Man",
+ "Europe/Istanbul",
+ "Europe/Jersey",
+ "Europe/Kaliningrad",
+ "Europe/Kiev",
+ "Europe/Kirov",
+ "Europe/Kyiv",
+ "Europe/Lisbon",
+ "Europe/Ljubljana",
+ "Europe/London",
+ "Europe/Luxembourg",
+ "Europe/Madrid",
+ "Europe/Malta",
+ "Europe/Mariehamn",
+ "Europe/Minsk",
+ "Europe/Monaco",
+ "Europe/Moscow",
+ "Europe/Nicosia",
+ "Europe/Oslo",
+ "Europe/Paris",
+ "Europe/Podgorica",
+ "Europe/Prague",
+ "Europe/Riga",
+ "Europe/Rome",
+ "Europe/Samara",
+ "Europe/San_Marino",
+ "Europe/Sarajevo",
+ "Europe/Saratov",
+ "Europe/Simferopol",
+ "Europe/Skopje",
+ "Europe/Sofia",
+ "Europe/Stockholm",
+ "Europe/Tallinn",
+ "Europe/Tirane",
+ "Europe/Tiraspol",
+ "Europe/Ulyanovsk",
+ "Europe/Uzhgorod",
+ "Europe/Vaduz",
+ "Europe/Vatican",
+ "Europe/Vienna",
+ "Europe/Vilnius",
+ "Europe/Volgograd",
+ "Europe/Warsaw",
+ "Europe/Zagreb",
+ "Europe/Zaporozhye",
+ "Europe/Zurich",
+ "GB",
+ "GB-Eire",
+ "GMT",
+ "GMT+0",
+ "GMT-0",
+ "GMT0",
+ "Greenwich",
+ "HST",
+ "Hongkong",
+ "Iceland",
+ "Indian/Antananarivo",
+ "Indian/Chagos",
+ "Indian/Christmas",
+ "Indian/Cocos",
+ "Indian/Comoro",
+ "Indian/Kerguelen",
+ "Indian/Mahe",
+ "Indian/Maldives",
+ "Indian/Mauritius",
+ "Indian/Mayotte",
+ "Indian/Reunion",
+ "Iran",
+ "Israel",
+ "Jamaica",
+ "Japan",
+ "Kwajalein",
+ "Libya",
+ "MET",
+ "MST",
+ "MST7MDT",
+ "Mexico/BajaNorte",
+ "Mexico/BajaSur",
+ "Mexico/General",
+ "NZ",
+ "NZ-CHAT",
+ "Navajo",
+ "PRC",
+ "PST8PDT",
+ "Pacific/Apia",
+ "Pacific/Auckland",
+ "Pacific/Bougainville",
+ "Pacific/Chatham",
+ "Pacific/Chuuk",
+ "Pacific/Easter",
+ "Pacific/Efate",
+ "Pacific/Enderbury",
+ "Pacific/Fakaofo",
+ "Pacific/Fiji",
+ "Pacific/Funafuti",
+ "Pacific/Galapagos",
+ "Pacific/Gambier",
+ "Pacific/Guadalcanal",
+ "Pacific/Guam",
+ "Pacific/Honolulu",
+ "Pacific/Johnston",
+ "Pacific/Kanton",
+ "Pacific/Kiritimati",
+ "Pacific/Kosrae",
+ "Pacific/Kwajalein",
+ "Pacific/Majuro",
+ "Pacific/Marquesas",
+ "Pacific/Midway",
+ "Pacific/Nauru",
+ "Pacific/Niue",
+ "Pacific/Norfolk",
+ "Pacific/Noumea",
+ "Pacific/Pago_Pago",
+ "Pacific/Palau",
+ "Pacific/Pitcairn",
+ "Pacific/Pohnpei",
+ "Pacific/Ponape",
+ "Pacific/Port_Moresby",
+ "Pacific/Rarotonga",
+ "Pacific/Saipan",
+ "Pacific/Samoa",
+ "Pacific/Tahiti",
+ "Pacific/Tarawa",
+ "Pacific/Tongatapu",
+ "Pacific/Truk",
+ "Pacific/Wake",
+ "Pacific/Wallis",
+ "Pacific/Yap",
+ "Poland",
+ "Portugal",
+ "ROC",
+ "ROK",
+ "Singapore",
+ "Turkey",
+ "UCT",
+ "US/Alaska",
+ "US/Aleutian",
+ "US/Arizona",
+ "US/Central",
+ "US/East-Indiana",
+ "US/Eastern",
+ "US/Hawaii",
+ "US/Indiana-Starke",
+ "US/Michigan",
+ "US/Mountain",
+ "US/Pacific",
+ "US/Samoa",
+ "UTC",
+ "Universal",
+ "W-SU",
+ "WET",
+ "Zulu"
+ ],
+ "minLength": 1,
+ "title": "Timezone"
+ }
+ },
+ "type": "object",
+ "required": ["timezone"],
+ "title": "UpdateTimezoneRequest"
+ },
"UploadFileResponse": {
"properties": {
"file_uri": { "type": "string", "title": "File Uri" },
diff --git a/autogpt_platform/frontend/src/components/cron-scheduler-dialog.tsx b/autogpt_platform/frontend/src/components/cron-scheduler-dialog.tsx
index aa92013ac2..3800370f3c 100644
--- a/autogpt_platform/frontend/src/components/cron-scheduler-dialog.tsx
+++ b/autogpt_platform/frontend/src/components/cron-scheduler-dialog.tsx
@@ -5,6 +5,9 @@ import { useToast } from "@/components/molecules/Toast/use-toast";
import { Separator } from "@/components/ui/separator";
import { CronScheduler } from "@/components/cron-scheduler";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
+import { getTimezoneDisplayName } from "@/lib/timezone-utils";
+import { InfoIcon } from "lucide-react";
type CronSchedulerDialogProps = {
open: boolean;
@@ -23,6 +26,11 @@ export function CronSchedulerDialog({
const [cronExpression, setCronExpression] = useState("");
const [scheduleName, setScheduleName] = useState(defaultScheduleName);
+ // Get user's timezone
+ const { data: timezoneData } = useGetV1GetUserTimezone();
+ const userTimezone = timezoneData?.data?.timezone || "UTC";
+ const timezoneDisplay = getTimezoneDisplayName(userTimezone);
+
// Reset state when dialog opens
useEffect(() => {
if (open) {
@@ -70,6 +78,27 @@ export function CronSchedulerDialog({
+
+ {/* Timezone info */}
+ {userTimezone === "not-set" ? (
+
+ ) : (
+
+
+
+ Schedule will run in your timezone:{" "}
+ {timezoneDisplay}
+
+
+ )}
diff --git a/autogpt_platform/frontend/src/components/monitor/scheduleTable.tsx b/autogpt_platform/frontend/src/components/monitor/scheduleTable.tsx
index cc27680c59..b4c3a4cce7 100644
--- a/autogpt_platform/frontend/src/components/monitor/scheduleTable.tsx
+++ b/autogpt_platform/frontend/src/components/monitor/scheduleTable.tsx
@@ -14,6 +14,11 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { ClockIcon, Loader2 } from "lucide-react";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
+import {
+ formatScheduleTime,
+ getTimezoneAbbreviation,
+} from "@/lib/timezone-utils";
+import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
import {
Select,
SelectContent,
@@ -59,6 +64,10 @@ export const SchedulesTable = ({
const [isLoading, setIsLoading] = useState(false);
const [selectedFilter, setSelectedFilter] = useState(""); // Graph ID
+ // Get user's timezone for displaying schedule times
+ const { data: timezoneData } = useGetV1GetUserTimezone();
+ const userTimezone = timezoneData?.data?.timezone || "UTC";
+
const filteredAndSortedSchedules = [...schedules]
.filter(
(schedule) => !selectedFilter || schedule.graph_id === selectedFilter,
@@ -218,7 +227,7 @@ export const SchedulesTable = ({
>
Schedule
-
+ Timezone
Actions
@@ -226,7 +235,7 @@ export const SchedulesTable = ({
{filteredAndSortedSchedules.length === 0 ? (
No schedules are available
@@ -241,14 +250,18 @@ export const SchedulesTable = ({
{schedule.graph_version}
- {schedule.next_run_time.toLocaleString()}
+ {formatScheduleTime(schedule.next_run_time, userTimezone)}
- {humanizeCronExpression(schedule.cron)}
+ {humanizeCronExpression(schedule.cron, userTimezone)}
-
+
+
+ {getTimezoneAbbreviation(userTimezone)}
+
+