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 + + +
+ + ( + + Select your 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" ? ( +
+ +

+ No timezone set. Schedule will run in UTC. + + Set your timezone + +

+
+ ) : ( +
+ +

+ 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)} + +