mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-09 07:08:09 -05:00
feat(backend): Add user timezone support to backend (#10707)
Co-authored-by: Swifty <craigswift13@gmail.com> resolve issue #10692 where scheduled time and actual run
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]):
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
115
autogpt_platform/backend/backend/util/timezone_name.py
Normal file
115
autogpt_platform/backend/backend/util/timezone_name.py
Normal file
@@ -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
|
||||
148
autogpt_platform/backend/backend/util/timezone_utils.py
Normal file
148
autogpt_platform/backend/backend/util/timezone_utils.py
Normal file
@@ -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"
|
||||
@@ -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);
|
||||
18
autogpt_platform/backend/poetry.lock
generated
18
autogpt_platform/backend/poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -36,6 +36,8 @@ model User {
|
||||
notifyOnAgentApproved Boolean @default(true)
|
||||
notifyOnAgentRejected Boolean @default(true)
|
||||
|
||||
timezone String @default("not-set")
|
||||
|
||||
// Relations
|
||||
|
||||
AgentGraphs AgentGraph[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-8">
|
||||
<EmailForm user={user} />
|
||||
<Separator />
|
||||
<TimezoneForm user={user} currentTimezone={timezone} />
|
||||
<Separator />
|
||||
<NotificationForm preferences={preferences} user={user} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timezone</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timezone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Select your timezone</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a timezone" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<SelectItem key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save timezone"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof formSchema>;
|
||||
|
||||
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<FormData>({
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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 <SettingsLoading />;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
</Text>
|
||||
</div>
|
||||
<SettingsForm preferences={preferences} user={user} />
|
||||
<SettingsForm preferences={preferences} user={user} timezone={timezone} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string>("");
|
||||
const [scheduleName, setScheduleName] = useState<string>(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({
|
||||
</div>
|
||||
|
||||
<CronScheduler onCronExpressionChange={setCronExpression} />
|
||||
|
||||
{/* Timezone info */}
|
||||
{userTimezone === "not-set" ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<InfoIcon className="h-4 w-4 text-amber-600" />
|
||||
<p className="text-sm text-amber-800">
|
||||
No timezone set. Schedule will run in UTC.
|
||||
<a href="/profile/settings" className="ml-1 underline">
|
||||
Set your timezone
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-md bg-muted/50 p-3">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Schedule will run in your timezone:{" "}
|
||||
<span className="font-medium">{timezoneDisplay}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
@@ -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<string>(""); // 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
|
||||
</TableHead>
|
||||
|
||||
<TableHead>Timezone</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -226,7 +235,7 @@ export const SchedulesTable = ({
|
||||
{filteredAndSortedSchedules.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
colSpan={6}
|
||||
className="py-8 text-center text-lg text-gray-400"
|
||||
>
|
||||
No schedules are available
|
||||
@@ -241,14 +250,18 @@ export const SchedulesTable = ({
|
||||
</TableCell>
|
||||
<TableCell>{schedule.graph_version}</TableCell>
|
||||
<TableCell>
|
||||
{schedule.next_run_time.toLocaleString()}
|
||||
{formatScheduleTime(schedule.next_run_time, userTimezone)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{humanizeCronExpression(schedule.cron)}
|
||||
{humanizeCronExpression(schedule.cron, userTimezone)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{getTimezoneAbbreviation(userTimezone)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { OnboardingStep, UserOnboarding } from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useOnboardingTimezoneDetection } from "@/hooks/useOnboardingTimezoneDetection";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -78,6 +79,9 @@ export default function OnboardingProvider({
|
||||
const router = useRouter();
|
||||
const { user, isUserLoading } = useSupabase();
|
||||
|
||||
// Automatically detect and set timezone for new users during onboarding
|
||||
useOnboardingTimezoneDetection();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOnboarding = async () => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePostV1UpdateUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
|
||||
|
||||
/**
|
||||
* Hook to silently detect and set user's timezone during onboarding
|
||||
* This version doesn't show any toast notifications
|
||||
* @returns void
|
||||
*/
|
||||
export const useOnboardingTimezoneDetection = () => {
|
||||
const updateTimezone = usePostV1UpdateUserTimezone();
|
||||
const hasAttemptedDetection = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only attempt once
|
||||
if (hasAttemptedDetection.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detectAndSetTimezone = async () => {
|
||||
// Mark that we've attempted detection
|
||||
hasAttemptedDetection.current = true;
|
||||
|
||||
try {
|
||||
// Detect browser timezone
|
||||
const browserTimezone =
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
if (!browserTimezone) {
|
||||
console.error("Could not detect browser timezone during onboarding");
|
||||
return;
|
||||
}
|
||||
|
||||
// Silently update the timezone in the backend
|
||||
await updateTimezone.mutateAsync({
|
||||
data: { timezone: browserTimezone } as any,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Timezone automatically set to ${browserTimezone} during onboarding`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to auto-detect timezone during onboarding:",
|
||||
error,
|
||||
);
|
||||
// Silent failure - user can still set timezone manually later
|
||||
}
|
||||
};
|
||||
|
||||
// Small delay to ensure user is created
|
||||
const timer = setTimeout(() => {
|
||||
detectAndSetTimezone();
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // Run once on mount
|
||||
};
|
||||
67
autogpt_platform/frontend/src/hooks/useTimezoneDetection.ts
Normal file
67
autogpt_platform/frontend/src/hooks/useTimezoneDetection.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { usePostV1UpdateUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Hook to automatically detect and set user's timezone if it's not set
|
||||
* @param currentTimezone - The current timezone value from the backend
|
||||
* @returns Object with detection status and manual trigger function
|
||||
*/
|
||||
export const useTimezoneDetection = (currentTimezone?: string) => {
|
||||
const updateTimezone = usePostV1UpdateUserTimezone();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const hasAttemptedDetection = useRef(false);
|
||||
|
||||
const detectAndSetTimezone = useCallback(async () => {
|
||||
// Mark that we've attempted detection to prevent multiple attempts
|
||||
hasAttemptedDetection.current = true;
|
||||
|
||||
try {
|
||||
// Detect browser timezone
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
if (!browserTimezone) {
|
||||
console.error("Could not detect browser timezone");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the timezone in the backend
|
||||
await updateTimezone.mutateAsync({
|
||||
data: { timezone: browserTimezone } as any,
|
||||
});
|
||||
|
||||
// Invalidate queries to refresh the data
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["/api/auth/user/timezone"],
|
||||
});
|
||||
|
||||
// Show success notification
|
||||
toast({
|
||||
title: "Timezone detected",
|
||||
description: `We've set your timezone to ${browserTimezone}. You can change this in settings.`,
|
||||
variant: "success",
|
||||
});
|
||||
|
||||
return browserTimezone;
|
||||
} catch (error) {
|
||||
console.error("Failed to auto-detect timezone:", error);
|
||||
// Silent failure - don't show error toast for auto-detection
|
||||
// User can still manually set timezone in settings
|
||||
}
|
||||
}, [updateTimezone, queryClient, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only proceed if timezone is "not-set" and we haven't already attempted detection
|
||||
if (currentTimezone !== "not-set" || hasAttemptedDetection.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
detectAndSetTimezone();
|
||||
}, [currentTimezone, detectAndSetTimezone]);
|
||||
|
||||
return {
|
||||
isNotSet: currentTimezone === "not-set",
|
||||
};
|
||||
};
|
||||
@@ -79,7 +79,10 @@ export function makeCronExpression(params: CronExpressionParams): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
export function humanizeCronExpression(cronExpression: string): string {
|
||||
export function humanizeCronExpression(
|
||||
cronExpression: string,
|
||||
userTimezone?: string,
|
||||
): string {
|
||||
const parts = cronExpression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
throw new Error("Invalid cron expression format.");
|
||||
@@ -135,7 +138,7 @@ export function humanizeCronExpression(cronExpression: string): string {
|
||||
!minute.includes("/") &&
|
||||
!hour.includes("/")
|
||||
) {
|
||||
return `Every day at ${formatTime(hour, minute)}`;
|
||||
return `Every day at ${formatTime(hour, minute, userTimezone)}`;
|
||||
}
|
||||
|
||||
// Handle weekly (e.g., 30 14 * * 1,3,5)
|
||||
@@ -147,7 +150,7 @@ export function humanizeCronExpression(cronExpression: string): string {
|
||||
!hour.includes("/")
|
||||
) {
|
||||
const days = getDayNames(dayOfWeek);
|
||||
return `Every ${days} at ${formatTime(hour, minute)}`;
|
||||
return `Every ${days} at ${formatTime(hour, minute, userTimezone)}`;
|
||||
}
|
||||
|
||||
// Handle monthly (e.g., 30 14 1,15 * *)
|
||||
@@ -160,7 +163,7 @@ export function humanizeCronExpression(cronExpression: string): string {
|
||||
) {
|
||||
const days = dayOfMonth.split(",").map(Number);
|
||||
const dayList = days.join(", ");
|
||||
return `On day ${dayList} of every month at ${formatTime(hour, minute)}`;
|
||||
return `On day ${dayList} of every month at ${formatTime(hour, minute, userTimezone)}`;
|
||||
}
|
||||
|
||||
// Handle yearly (e.g., 30 14 1 1,6,12 *)
|
||||
@@ -172,7 +175,7 @@ export function humanizeCronExpression(cronExpression: string): string {
|
||||
!hour.includes("/")
|
||||
) {
|
||||
const months = getMonthNames(month);
|
||||
return `Every year on the 1st day of ${months} at ${formatTime(hour, minute)}`;
|
||||
return `Every year on the 1st day of ${months} at ${formatTime(hour, minute, userTimezone)}`;
|
||||
}
|
||||
|
||||
// Handle custom minute intervals with other fields as * (e.g., every N minutes)
|
||||
@@ -208,13 +211,41 @@ export function humanizeCronExpression(cronExpression: string): string {
|
||||
!hour.includes("/")
|
||||
) {
|
||||
const interval = dayOfMonth.substring(2);
|
||||
return `Every ${interval} days at ${formatTime(hour, minute)}`;
|
||||
return `Every ${interval} days at ${formatTime(hour, minute, userTimezone)}`;
|
||||
}
|
||||
|
||||
return `Cron Expression: ${cronExpression}`;
|
||||
}
|
||||
|
||||
function formatTime(hour: string, minute: string): string {
|
||||
function formatTime(
|
||||
hour: string,
|
||||
minute: string,
|
||||
userTimezone?: string,
|
||||
): string {
|
||||
// Convert from UTC cron time to user timezone for display consistency with next_run_time
|
||||
if (userTimezone && userTimezone !== "UTC" && userTimezone !== "not-set") {
|
||||
try {
|
||||
// Create a date in UTC with the cron hour/minute (cron expressions are stored in UTC)
|
||||
const utcDate = new Date();
|
||||
utcDate.setUTCHours(parseInt(hour), parseInt(minute), 0, 0);
|
||||
|
||||
// Format in user's timezone to match next_run_time display
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
return formatter.format(utcDate);
|
||||
} catch {
|
||||
// Fallback to original formatting if timezone conversion fails
|
||||
const formattedHour = padZero(hour);
|
||||
const formattedMinute = padZero(minute);
|
||||
return `${formattedHour}:${formattedMinute}`;
|
||||
}
|
||||
}
|
||||
|
||||
const formattedHour = padZero(hour);
|
||||
const formattedMinute = padZero(minute);
|
||||
return `${formattedHour}:${formattedMinute}`;
|
||||
|
||||
115
autogpt_platform/frontend/src/lib/timezone-utils.ts
Normal file
115
autogpt_platform/frontend/src/lib/timezone-utils.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Utility functions for timezone conversions and display
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a date/time in the user's timezone with timezone indicator
|
||||
* @param date - The date to format (can be string or Date)
|
||||
* @param timezone - The IANA timezone identifier (e.g., "America/New_York")
|
||||
* @param options - Intl.DateTimeFormat options
|
||||
* @returns Formatted date string with timezone
|
||||
*/
|
||||
export function formatInTimezone(
|
||||
date: string | Date,
|
||||
timezone: string,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
): string {
|
||||
const dateObj = typeof date === "string" ? new Date(date) : date;
|
||||
|
||||
const defaultOptions: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
...options,
|
||||
};
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
...defaultOptions,
|
||||
timeZone: timezone === "not-set" ? undefined : timezone,
|
||||
}).format(dateObj);
|
||||
} catch {
|
||||
// Fallback to local timezone if invalid timezone
|
||||
console.warn(`Invalid timezone "${timezone}", using local timezone`);
|
||||
return new Intl.DateTimeFormat("en-US", defaultOptions).format(dateObj);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timezone abbreviation (e.g., "EST", "PST")
|
||||
* @param timezone - The IANA timezone identifier
|
||||
* @returns Timezone abbreviation
|
||||
*/
|
||||
export function getTimezoneAbbreviation(timezone: string): string {
|
||||
if (timezone === "not-set" || !timezone) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date();
|
||||
const formatted = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
timeZoneName: "short",
|
||||
}).format(date);
|
||||
|
||||
// Extract the timezone abbreviation from the formatted string
|
||||
const match = formatted.match(/[A-Z]{2,5}$/);
|
||||
return match ? match[0] : timezone;
|
||||
} catch {
|
||||
return timezone;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for schedule display with timezone context
|
||||
* @param nextRunTime - The next run time (UTC)
|
||||
* @param displayTimezone - The timezone to display the time in (typically user's timezone)
|
||||
* @returns Formatted string in the specified timezone
|
||||
*/
|
||||
export function formatScheduleTime(
|
||||
nextRunTime: string | Date,
|
||||
displayTimezone: string,
|
||||
): string {
|
||||
const date =
|
||||
typeof nextRunTime === "string" ? new Date(nextRunTime) : nextRunTime;
|
||||
|
||||
// Use provided timezone for display, fallback to UTC
|
||||
const timezone = displayTimezone || "UTC";
|
||||
const formatted = formatInTimezone(date, timezone, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable timezone name
|
||||
* @param timezone - IANA timezone identifier
|
||||
* @returns Human-readable name
|
||||
*/
|
||||
export function getTimezoneDisplayName(timezone: string): string {
|
||||
if (timezone === "not-set") {
|
||||
return "Not set";
|
||||
}
|
||||
|
||||
if (timezone === "UTC") {
|
||||
return "UTC";
|
||||
}
|
||||
|
||||
// Convert America/New_York to "New York (EST)"
|
||||
try {
|
||||
const parts = timezone.split("/");
|
||||
const city = parts[parts.length - 1].replace(/_/g, " ");
|
||||
const abbr = getTimezoneAbbreviation(timezone);
|
||||
return abbr ? `${city} (${abbr})` : city;
|
||||
} catch {
|
||||
return timezone;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user