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:
Nicholas Tindle
2025-08-25 11:00:07 -05:00
committed by GitHub
parent 76090f0ba2
commit 2bb8e91040
30 changed files with 2422 additions and 61 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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]):

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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()

View 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

View 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"

View File

@@ -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);

View File

@@ -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"

View File

@@ -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"

View File

@@ -36,6 +36,8 @@ model User {
notifyOnAgentApproved Boolean @default(true)
notifyOnAgentRejected Boolean @default(true)
timezone String @default("not-set")
// Relations
AgentGraphs AgentGraph[]

View File

@@ -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,

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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,
};
};

View File

@@ -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

View File

@@ -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" />

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
};

View 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",
};
};

View File

@@ -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}`;

View 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;
}
}