mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
fix: address critical issues in PR #75 agent scheduling feature
This commit fixes several issues identified in the agent scheduling feature from PR #75: Frontend Fixes: - Add day boundary handling in timeUtils.ts for timezone conversions - Add utcToLocalWithDayShift/localToUTCWithDayShift functions - Add shiftDaysForward/shiftDaysBackward helpers for bitfield adjustment - Update ScheduleModal to correctly adjust days_of_week when crossing day boundaries during UTC conversion (fixes schedules running on wrong days for users in extreme timezones like UTC+9) Backend Fixes: - Add MAX_SCHEDULES_PER_PROJECT (50) limit to prevent resource exhaustion - Wire up crash recovery callback in scheduler_service._start_agent() - Convert schedules.py endpoints to use context manager for DB sessions - Fix race condition in override creation with atomic delete-then-create - Replace deprecated datetime.utcnow with datetime.now(timezone.utc) - Add DB-level CHECK constraints for Schedule model fields Files Modified: - api/database.py: Add _utc_now helper, CheckConstraint imports, constraints - progress.py: Replace deprecated datetime.utcnow - server/routers/schedules.py: Add context manager, schedule limits - server/services/assistant_database.py: Replace deprecated datetime.utcnow - server/services/scheduler_service.py: Wire crash recovery, fix race condition - ui/src/components/ScheduleModal.tsx: Use day shift functions - ui/src/lib/timeUtils.ts: Add day boundary handling functions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,11 +6,27 @@ SQLite database schema for feature storage using SQLAlchemy.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, create_engine, text
|
|
||||||
|
def _utc_now() -> datetime:
|
||||||
|
"""Return current UTC time. Replacement for deprecated _utc_now()."""
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
create_engine,
|
||||||
|
text,
|
||||||
|
)
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import Session, relationship, sessionmaker
|
from sqlalchemy.orm import Session, relationship, sessionmaker
|
||||||
from sqlalchemy.types import JSON
|
from sqlalchemy.types import JSON
|
||||||
@@ -65,6 +81,14 @@ class Schedule(Base):
|
|||||||
|
|
||||||
__tablename__ = "schedules"
|
__tablename__ = "schedules"
|
||||||
|
|
||||||
|
# Database-level CHECK constraints for data integrity
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint('duration_minutes >= 1 AND duration_minutes <= 1440', name='ck_schedule_duration'),
|
||||||
|
CheckConstraint('days_of_week >= 0 AND days_of_week <= 127', name='ck_schedule_days'),
|
||||||
|
CheckConstraint('max_concurrency >= 1 AND max_concurrency <= 5', name='ck_schedule_concurrency'),
|
||||||
|
CheckConstraint('crash_count >= 0', name='ck_schedule_crash_count'),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
project_name = Column(String(50), nullable=False, index=True)
|
project_name = Column(String(50), nullable=False, index=True)
|
||||||
|
|
||||||
@@ -87,7 +111,7 @@ class Schedule(Base):
|
|||||||
crash_count = Column(Integer, nullable=False, default=0) # Resets at window start
|
crash_count = Column(Integer, nullable=False, default=0) # Resets at window start
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
created_at = Column(DateTime, nullable=False, default=_utc_now)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
overrides = relationship(
|
overrides = relationship(
|
||||||
@@ -131,7 +155,7 @@ class ScheduleOverride(Base):
|
|||||||
expires_at = Column(DateTime, nullable=False) # When this window ends (UTC)
|
expires_at = Column(DateTime, nullable=False) # When this window ends (UTC)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
created_at = Column(DateTime, nullable=False, default=_utc_now)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
schedule = relationship("Schedule", back_populates="overrides")
|
schedule = relationship("Schedule", back_populates="overrides")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
WEBHOOK_URL = os.environ.get("PROGRESS_N8N_WEBHOOK_URL")
|
WEBHOOK_URL = os.environ.get("PROGRESS_N8N_WEBHOOK_URL")
|
||||||
@@ -171,7 +171,7 @@ def send_progress_webhook(passing: int, total: int, project_dir: Path) -> None:
|
|||||||
"tests_completed_this_session": passing - previous,
|
"tests_completed_this_session": passing - previous,
|
||||||
"completed_tests": completed_tests,
|
"completed_tests": completed_tests,
|
||||||
"project": project_dir.name,
|
"project": project_dir.name,
|
||||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ Provides CRUD operations for time-based schedule configuration.
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Generator, Tuple
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
# Schedule limits to prevent resource exhaustion
|
||||||
|
MAX_SCHEDULES_PER_PROJECT = 50
|
||||||
|
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
NextRunResponse,
|
NextRunResponse,
|
||||||
@@ -48,8 +54,15 @@ def validate_project_name(name: str) -> str:
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
def _get_db_session(project_name: str):
|
@contextmanager
|
||||||
"""Get database session for a project."""
|
def _get_db_session(project_name: str) -> Generator[Tuple[Session, Path], None, None]:
|
||||||
|
"""Get database session for a project as a context manager.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
with _get_db_session(project_name) as (db, project_path):
|
||||||
|
# ... use db ...
|
||||||
|
# db is automatically closed
|
||||||
|
"""
|
||||||
from api.database import create_database
|
from api.database import create_database
|
||||||
|
|
||||||
project_name = validate_project_name(project_name)
|
project_name = validate_project_name(project_name)
|
||||||
@@ -68,7 +81,11 @@ def _get_db_session(project_name: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
_, SessionLocal = create_database(project_path)
|
_, SessionLocal = create_database(project_path)
|
||||||
return SessionLocal(), project_path
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db, project_path
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ScheduleListResponse)
|
@router.get("", response_model=ScheduleListResponse)
|
||||||
@@ -76,9 +93,7 @@ async def list_schedules(project_name: str):
|
|||||||
"""Get all schedules for a project."""
|
"""Get all schedules for a project."""
|
||||||
from api.database import Schedule
|
from api.database import Schedule
|
||||||
|
|
||||||
db, _ = _get_db_session(project_name)
|
with _get_db_session(project_name) as (db, _):
|
||||||
|
|
||||||
try:
|
|
||||||
schedules = db.query(Schedule).filter(
|
schedules = db.query(Schedule).filter(
|
||||||
Schedule.project_name == project_name
|
Schedule.project_name == project_name
|
||||||
).order_by(Schedule.start_time).all()
|
).order_by(Schedule.start_time).all()
|
||||||
@@ -100,8 +115,6 @@ async def list_schedules(project_name: str):
|
|||||||
for s in schedules
|
for s in schedules
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ScheduleResponse, status_code=201)
|
@router.post("", response_model=ScheduleResponse, status_code=201)
|
||||||
@@ -111,9 +124,18 @@ async def create_schedule(project_name: str, data: ScheduleCreate):
|
|||||||
|
|
||||||
from ..services.scheduler_service import get_scheduler
|
from ..services.scheduler_service import get_scheduler
|
||||||
|
|
||||||
db, project_path = _get_db_session(project_name)
|
with _get_db_session(project_name) as (db, project_path):
|
||||||
|
# Check schedule limit to prevent resource exhaustion
|
||||||
|
existing_count = db.query(Schedule).filter(
|
||||||
|
Schedule.project_name == project_name
|
||||||
|
).count()
|
||||||
|
|
||||||
|
if existing_count >= MAX_SCHEDULES_PER_PROJECT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Maximum schedules per project ({MAX_SCHEDULES_PER_PROJECT}) exceeded"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
|
||||||
# Create schedule record
|
# Create schedule record
|
||||||
schedule = Schedule(
|
schedule = Schedule(
|
||||||
project_name=project_name,
|
project_name=project_name,
|
||||||
@@ -178,9 +200,6 @@ async def create_schedule(project_name: str, data: ScheduleCreate):
|
|||||||
created_at=schedule.created_at,
|
created_at=schedule.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/next", response_model=NextRunResponse)
|
@router.get("/next", response_model=NextRunResponse)
|
||||||
async def get_next_scheduled_run(project_name: str):
|
async def get_next_scheduled_run(project_name: str):
|
||||||
@@ -189,9 +208,7 @@ async def get_next_scheduled_run(project_name: str):
|
|||||||
|
|
||||||
from ..services.scheduler_service import get_scheduler
|
from ..services.scheduler_service import get_scheduler
|
||||||
|
|
||||||
db, _ = _get_db_session(project_name)
|
with _get_db_session(project_name) as (db, _):
|
||||||
|
|
||||||
try:
|
|
||||||
schedules = db.query(Schedule).filter(
|
schedules = db.query(Schedule).filter(
|
||||||
Schedule.project_name == project_name,
|
Schedule.project_name == project_name,
|
||||||
Schedule.enabled == True, # noqa: E712
|
Schedule.enabled == True, # noqa: E712
|
||||||
@@ -245,18 +262,13 @@ async def get_next_scheduled_run(project_name: str):
|
|||||||
active_schedule_count=active_count,
|
active_schedule_count=active_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{schedule_id}", response_model=ScheduleResponse)
|
@router.get("/{schedule_id}", response_model=ScheduleResponse)
|
||||||
async def get_schedule(project_name: str, schedule_id: int):
|
async def get_schedule(project_name: str, schedule_id: int):
|
||||||
"""Get a single schedule by ID."""
|
"""Get a single schedule by ID."""
|
||||||
from api.database import Schedule
|
from api.database import Schedule
|
||||||
|
|
||||||
db, _ = _get_db_session(project_name)
|
with _get_db_session(project_name) as (db, _):
|
||||||
|
|
||||||
try:
|
|
||||||
schedule = db.query(Schedule).filter(
|
schedule = db.query(Schedule).filter(
|
||||||
Schedule.id == schedule_id,
|
Schedule.id == schedule_id,
|
||||||
Schedule.project_name == project_name,
|
Schedule.project_name == project_name,
|
||||||
@@ -278,9 +290,6 @@ async def get_schedule(project_name: str, schedule_id: int):
|
|||||||
created_at=schedule.created_at,
|
created_at=schedule.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{schedule_id}", response_model=ScheduleResponse)
|
@router.patch("/{schedule_id}", response_model=ScheduleResponse)
|
||||||
async def update_schedule(
|
async def update_schedule(
|
||||||
@@ -293,9 +302,7 @@ async def update_schedule(
|
|||||||
|
|
||||||
from ..services.scheduler_service import get_scheduler
|
from ..services.scheduler_service import get_scheduler
|
||||||
|
|
||||||
db, project_path = _get_db_session(project_name)
|
with _get_db_session(project_name) as (db, project_path):
|
||||||
|
|
||||||
try:
|
|
||||||
schedule = db.query(Schedule).filter(
|
schedule = db.query(Schedule).filter(
|
||||||
Schedule.id == schedule_id,
|
Schedule.id == schedule_id,
|
||||||
Schedule.project_name == project_name,
|
Schedule.project_name == project_name,
|
||||||
@@ -337,9 +344,6 @@ async def update_schedule(
|
|||||||
created_at=schedule.created_at,
|
created_at=schedule.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{schedule_id}", status_code=204)
|
@router.delete("/{schedule_id}", status_code=204)
|
||||||
async def delete_schedule(project_name: str, schedule_id: int):
|
async def delete_schedule(project_name: str, schedule_id: int):
|
||||||
@@ -348,9 +352,7 @@ async def delete_schedule(project_name: str, schedule_id: int):
|
|||||||
|
|
||||||
from ..services.scheduler_service import get_scheduler
|
from ..services.scheduler_service import get_scheduler
|
||||||
|
|
||||||
db, _ = _get_db_session(project_name)
|
with _get_db_session(project_name) as (db, _):
|
||||||
|
|
||||||
try:
|
|
||||||
schedule = db.query(Schedule).filter(
|
schedule = db.query(Schedule).filter(
|
||||||
Schedule.id == schedule_id,
|
Schedule.id == schedule_id,
|
||||||
Schedule.project_name == project_name,
|
Schedule.project_name == project_name,
|
||||||
@@ -367,9 +369,6 @@ async def delete_schedule(project_name: str, schedule_id: int):
|
|||||||
db.delete(schedule)
|
db.delete(schedule)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_window_end(schedule, now: datetime) -> datetime:
|
def _calculate_window_end(schedule, now: datetime) -> datetime:
|
||||||
"""Calculate when the current window ends."""
|
"""Calculate when the current window ends."""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Each project has its own assistant.db file in the project directory.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -19,6 +19,11 @@ logger = logging.getLogger(__name__)
|
|||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> datetime:
|
||||||
|
"""Return current UTC time. Replacement for deprecated datetime.utcnow()."""
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class Conversation(Base):
|
class Conversation(Base):
|
||||||
"""A conversation with the assistant for a project."""
|
"""A conversation with the assistant for a project."""
|
||||||
__tablename__ = "conversations"
|
__tablename__ = "conversations"
|
||||||
@@ -26,8 +31,8 @@ class Conversation(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
project_name = Column(String(100), nullable=False, index=True)
|
project_name = Column(String(100), nullable=False, index=True)
|
||||||
title = Column(String(200), nullable=True) # Optional title, derived from first message
|
title = Column(String(200), nullable=True) # Optional title, derived from first message
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=_utc_now)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=_utc_now, onupdate=_utc_now)
|
||||||
|
|
||||||
messages = relationship("ConversationMessage", back_populates="conversation", cascade="all, delete-orphan")
|
messages = relationship("ConversationMessage", back_populates="conversation", cascade="all, delete-orphan")
|
||||||
|
|
||||||
@@ -40,7 +45,7 @@ class ConversationMessage(Base):
|
|||||||
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False, index=True)
|
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False, index=True)
|
||||||
role = Column(String(20), nullable=False) # "user" | "assistant" | "system"
|
role = Column(String(20), nullable=False) # "user" | "assistant" | "system"
|
||||||
content = Column(Text, nullable=False)
|
content = Column(Text, nullable=False)
|
||||||
timestamp = Column(DateTime, default=datetime.utcnow)
|
timestamp = Column(DateTime, default=_utc_now)
|
||||||
|
|
||||||
conversation = relationship("Conversation", back_populates="messages")
|
conversation = relationship("Conversation", back_populates="messages")
|
||||||
|
|
||||||
@@ -175,7 +180,7 @@ def add_message(project_dir: Path, conversation_id: int, role: str, content: str
|
|||||||
session.add(message)
|
session.add(message)
|
||||||
|
|
||||||
# Update conversation's updated_at timestamp
|
# Update conversation's updated_at timestamp
|
||||||
conversation.updated_at = datetime.utcnow()
|
conversation.updated_at = _utc_now()
|
||||||
|
|
||||||
# Auto-generate title from first user message if not set
|
# Auto-generate title from first user message if not set
|
||||||
if not conversation.title and role == "user":
|
if not conversation.title and role == "user":
|
||||||
|
|||||||
@@ -368,6 +368,14 @@ class SchedulerService:
|
|||||||
logger.info(f"Agent already running for {project_name}, skipping scheduled start")
|
logger.info(f"Agent already running for {project_name}, skipping scheduled start")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Register crash callback to enable auto-restart during scheduled windows
|
||||||
|
async def on_status_change(status: str):
|
||||||
|
if status == "crashed":
|
||||||
|
logger.info(f"Crash detected for {project_name}, attempting recovery")
|
||||||
|
await self.handle_crash_during_window(project_name, project_dir)
|
||||||
|
|
||||||
|
manager.add_status_callback(on_status_change)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Starting agent for {project_name} "
|
f"Starting agent for {project_name} "
|
||||||
f"(schedule {schedule.id}, yolo={schedule.yolo_mode}, concurrency={schedule.max_concurrency})"
|
f"(schedule {schedule.id}, yolo={schedule.yolo_mode}, concurrency={schedule.max_concurrency})"
|
||||||
@@ -382,6 +390,8 @@ class SchedulerService:
|
|||||||
logger.info(f"✓ Agent started successfully for {project_name}")
|
logger.info(f"✓ Agent started successfully for {project_name}")
|
||||||
else:
|
else:
|
||||||
logger.error(f"✗ Failed to start agent for {project_name}: {msg}")
|
logger.error(f"✗ Failed to start agent for {project_name}: {msg}")
|
||||||
|
# Remove callback if start failed
|
||||||
|
manager.remove_status_callback(on_status_change)
|
||||||
|
|
||||||
async def _stop_agent(self, project_name: str, project_dir: Path):
|
async def _stop_agent(self, project_name: str, project_dir: Path):
|
||||||
"""Stop the agent for a project."""
|
"""Stop the agent for a project."""
|
||||||
@@ -457,7 +467,10 @@ class SchedulerService:
|
|||||||
def _create_override_for_active_schedules(
|
def _create_override_for_active_schedules(
|
||||||
self, project_name: str, project_dir: Path, override_type: str
|
self, project_name: str, project_dir: Path, override_type: str
|
||||||
):
|
):
|
||||||
"""Create overrides for all active schedule windows."""
|
"""Create overrides for all active schedule windows.
|
||||||
|
|
||||||
|
Uses atomic delete-then-create pattern to prevent race conditions.
|
||||||
|
"""
|
||||||
from api.database import Schedule, ScheduleOverride, create_database
|
from api.database import Schedule, ScheduleOverride, create_database
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -479,17 +492,20 @@ class SchedulerService:
|
|||||||
# Calculate window end time
|
# Calculate window end time
|
||||||
window_end = self._calculate_window_end(schedule, now)
|
window_end = self._calculate_window_end(schedule, now)
|
||||||
|
|
||||||
# Check if override already exists
|
# Atomic operation: delete any existing overrides of this type
|
||||||
existing = db.query(ScheduleOverride).filter(
|
# and create a new one in the same transaction
|
||||||
|
deleted = db.query(ScheduleOverride).filter(
|
||||||
ScheduleOverride.schedule_id == schedule.id,
|
ScheduleOverride.schedule_id == schedule.id,
|
||||||
ScheduleOverride.override_type == override_type,
|
ScheduleOverride.override_type == override_type,
|
||||||
ScheduleOverride.expires_at > now,
|
).delete()
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
if deleted:
|
||||||
continue
|
logger.debug(
|
||||||
|
f"Removed {deleted} existing '{override_type}' override(s) "
|
||||||
|
f"for schedule {schedule.id}"
|
||||||
|
)
|
||||||
|
|
||||||
# Create override
|
# Create new override
|
||||||
override = ScheduleOverride(
|
override = ScheduleOverride(
|
||||||
schedule_id=schedule.id,
|
schedule_id=schedule.id,
|
||||||
override_type=override_type,
|
override_type=override_type,
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import {
|
|||||||
useToggleSchedule,
|
useToggleSchedule,
|
||||||
} from '../hooks/useSchedules'
|
} from '../hooks/useSchedules'
|
||||||
import {
|
import {
|
||||||
utcToLocal,
|
utcToLocalWithDayShift,
|
||||||
localToUTC,
|
localToUTCWithDayShift,
|
||||||
|
adjustDaysForDayShift,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
DAYS,
|
DAYS,
|
||||||
isDayActive,
|
isDayActive,
|
||||||
@@ -109,10 +110,18 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert local time to UTC
|
// Convert local time to UTC and get day shift
|
||||||
|
const { time: utcTime, dayShift } = localToUTCWithDayShift(newSchedule.start_time)
|
||||||
|
|
||||||
|
// Adjust days_of_week based on day shift
|
||||||
|
// If UTC is on the next day (dayShift = 1), shift days forward
|
||||||
|
// If UTC is on the previous day (dayShift = -1), shift days backward
|
||||||
|
const adjustedDays = adjustDaysForDayShift(newSchedule.days_of_week, dayShift)
|
||||||
|
|
||||||
const scheduleToCreate = {
|
const scheduleToCreate = {
|
||||||
...newSchedule,
|
...newSchedule,
|
||||||
start_time: localToUTC(newSchedule.start_time),
|
start_time: utcTime,
|
||||||
|
days_of_week: adjustedDays,
|
||||||
}
|
}
|
||||||
|
|
||||||
await createSchedule.mutateAsync(scheduleToCreate)
|
await createSchedule.mutateAsync(scheduleToCreate)
|
||||||
@@ -203,8 +212,12 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
|
|||||||
{!isLoading && schedules.length > 0 && (
|
{!isLoading && schedules.length > 0 && (
|
||||||
<div className="space-y-3 mb-6 max-h-[300px] overflow-y-auto">
|
<div className="space-y-3 mb-6 max-h-[300px] overflow-y-auto">
|
||||||
{schedules.map((schedule) => {
|
{schedules.map((schedule) => {
|
||||||
const localTime = utcToLocal(schedule.start_time)
|
// Convert UTC time to local and get day shift for display
|
||||||
|
const { time: localTime, dayShift } = utcToLocalWithDayShift(schedule.start_time)
|
||||||
const duration = formatDuration(schedule.duration_minutes)
|
const duration = formatDuration(schedule.duration_minutes)
|
||||||
|
// Adjust displayed days: if local is next day (dayShift=1), shift forward
|
||||||
|
// if local is prev day (dayShift=-1), shift backward
|
||||||
|
const displayDays = adjustDaysForDayShift(schedule.days_of_week, dayShift)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -223,7 +236,7 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
|
|||||||
{/* Days */}
|
{/* Days */}
|
||||||
<div className="flex gap-1 mb-2">
|
<div className="flex gap-1 mb-2">
|
||||||
{DAYS.map((day) => {
|
{DAYS.map((day) => {
|
||||||
const isActive = isDayActive(schedule.days_of_week, day.bit)
|
const isActive = isDayActive(displayDays, day.bit)
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={day.label}
|
key={day.label}
|
||||||
|
|||||||
@@ -4,38 +4,135 @@
|
|||||||
*
|
*
|
||||||
* Utilities for converting between UTC and local time for schedule management.
|
* Utilities for converting between UTC and local time for schedule management.
|
||||||
* All times in the database are stored in UTC and displayed in the user's local timezone.
|
* All times in the database are stored in UTC and displayed in the user's local timezone.
|
||||||
|
*
|
||||||
|
* IMPORTANT: When converting times, day boundaries may be crossed. For example:
|
||||||
|
* - Tokyo (UTC+9) user sets 02:00 local → 17:00 UTC previous day
|
||||||
|
* - New York (UTC-5) user sets 22:00 local → 03:00 UTC next day
|
||||||
|
*
|
||||||
|
* The days_of_week bitfield must be adjusted accordingly using shiftDaysForward/shiftDaysBackward.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of time conversion including day shift information.
|
||||||
|
*/
|
||||||
|
export interface TimeConversionResult {
|
||||||
|
time: string
|
||||||
|
dayShift: -1 | 0 | 1 // -1 = previous day, 0 = same day, 1 = next day
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert "HH:MM" UTC time to user's local time.
|
* Convert "HH:MM" UTC time to user's local time.
|
||||||
* @param utcTime Time string in "HH:MM" format (UTC)
|
* @param utcTime Time string in "HH:MM" format (UTC)
|
||||||
* @returns Time string in "HH:MM" format (local)
|
* @returns Object with local time string and day shift indicator
|
||||||
*/
|
*/
|
||||||
export function utcToLocal(utcTime: string): string {
|
export function utcToLocalWithDayShift(utcTime: string): TimeConversionResult {
|
||||||
const [hours, minutes] = utcTime.split(':').map(Number)
|
const [hours, minutes] = utcTime.split(':').map(Number)
|
||||||
const utcDate = new Date()
|
|
||||||
utcDate.setUTCHours(hours, minutes, 0, 0)
|
// Use a fixed reference date to calculate the shift
|
||||||
|
const utcDate = new Date(Date.UTC(2000, 0, 15, hours, minutes, 0, 0)) // Jan 15, 2000
|
||||||
|
const localDay = utcDate.getDate()
|
||||||
|
|
||||||
|
let dayShift: -1 | 0 | 1 = 0
|
||||||
|
if (localDay === 14) dayShift = -1 // Went to previous day
|
||||||
|
if (localDay === 16) dayShift = 1 // Went to next day
|
||||||
|
|
||||||
const localHours = utcDate.getHours()
|
const localHours = utcDate.getHours()
|
||||||
const localMinutes = utcDate.getMinutes()
|
const localMinutes = utcDate.getMinutes()
|
||||||
|
|
||||||
return `${String(localHours).padStart(2, '0')}:${String(localMinutes).padStart(2, '0')}`
|
return {
|
||||||
|
time: `${String(localHours).padStart(2, '0')}:${String(localMinutes).padStart(2, '0')}`,
|
||||||
|
dayShift,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert "HH:MM" UTC time to user's local time (legacy function for backwards compatibility).
|
||||||
|
* @param utcTime Time string in "HH:MM" format (UTC)
|
||||||
|
* @returns Time string in "HH:MM" format (local)
|
||||||
|
*/
|
||||||
|
export function utcToLocal(utcTime: string): string {
|
||||||
|
return utcToLocalWithDayShift(utcTime).time
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert "HH:MM" local time to UTC for storage.
|
* Convert "HH:MM" local time to UTC for storage.
|
||||||
* @param localTime Time string in "HH:MM" format (local)
|
* @param localTime Time string in "HH:MM" format (local)
|
||||||
* @returns Time string in "HH:MM" format (UTC)
|
* @returns Object with UTC time string and day shift indicator
|
||||||
*/
|
*/
|
||||||
export function localToUTC(localTime: string): string {
|
export function localToUTCWithDayShift(localTime: string): TimeConversionResult {
|
||||||
const [hours, minutes] = localTime.split(':').map(Number)
|
const [hours, minutes] = localTime.split(':').map(Number)
|
||||||
const localDate = new Date()
|
|
||||||
localDate.setHours(hours, minutes, 0, 0)
|
// Use a fixed reference date to calculate the shift
|
||||||
|
// Set local time on Jan 15, then check UTC date
|
||||||
|
const localDate = new Date(2000, 0, 15, hours, minutes, 0, 0) // Jan 15, 2000 local
|
||||||
|
const utcDay = localDate.getUTCDate()
|
||||||
|
|
||||||
|
let dayShift: -1 | 0 | 1 = 0
|
||||||
|
if (utcDay === 14) dayShift = -1 // UTC is previous day
|
||||||
|
if (utcDay === 16) dayShift = 1 // UTC is next day
|
||||||
|
|
||||||
const utcHours = localDate.getUTCHours()
|
const utcHours = localDate.getUTCHours()
|
||||||
const utcMinutes = localDate.getUTCMinutes()
|
const utcMinutes = localDate.getUTCMinutes()
|
||||||
|
|
||||||
return `${String(utcHours).padStart(2, '0')}:${String(utcMinutes).padStart(2, '0')}`
|
return {
|
||||||
|
time: `${String(utcHours).padStart(2, '0')}:${String(utcMinutes).padStart(2, '0')}`,
|
||||||
|
dayShift,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert "HH:MM" local time to UTC for storage (legacy function for backwards compatibility).
|
||||||
|
* @param localTime Time string in "HH:MM" format (local)
|
||||||
|
* @returns Time string in "HH:MM" format (UTC)
|
||||||
|
*/
|
||||||
|
export function localToUTC(localTime: string): string {
|
||||||
|
return localToUTCWithDayShift(localTime).time
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift days_of_week bitfield forward by one day.
|
||||||
|
* Used when UTC time is on the next day relative to local time.
|
||||||
|
* Example: Mon(1) -> Tue(2), Sun(64) -> Mon(1)
|
||||||
|
*/
|
||||||
|
export function shiftDaysForward(bitfield: number): number {
|
||||||
|
let shifted = 0
|
||||||
|
if (bitfield & 1) shifted |= 2 // Mon -> Tue
|
||||||
|
if (bitfield & 2) shifted |= 4 // Tue -> Wed
|
||||||
|
if (bitfield & 4) shifted |= 8 // Wed -> Thu
|
||||||
|
if (bitfield & 8) shifted |= 16 // Thu -> Fri
|
||||||
|
if (bitfield & 16) shifted |= 32 // Fri -> Sat
|
||||||
|
if (bitfield & 32) shifted |= 64 // Sat -> Sun
|
||||||
|
if (bitfield & 64) shifted |= 1 // Sun -> Mon
|
||||||
|
return shifted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift days_of_week bitfield backward by one day.
|
||||||
|
* Used when UTC time is on the previous day relative to local time.
|
||||||
|
* Example: Tue(2) -> Mon(1), Mon(1) -> Sun(64)
|
||||||
|
*/
|
||||||
|
export function shiftDaysBackward(bitfield: number): number {
|
||||||
|
let shifted = 0
|
||||||
|
if (bitfield & 1) shifted |= 64 // Mon -> Sun
|
||||||
|
if (bitfield & 2) shifted |= 1 // Tue -> Mon
|
||||||
|
if (bitfield & 4) shifted |= 2 // Wed -> Tue
|
||||||
|
if (bitfield & 8) shifted |= 4 // Thu -> Wed
|
||||||
|
if (bitfield & 16) shifted |= 8 // Fri -> Thu
|
||||||
|
if (bitfield & 32) shifted |= 16 // Sat -> Fri
|
||||||
|
if (bitfield & 64) shifted |= 32 // Sun -> Sat
|
||||||
|
return shifted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjust days_of_week bitfield based on day shift from time conversion.
|
||||||
|
* @param bitfield Original days_of_week bitfield
|
||||||
|
* @param dayShift Day shift from time conversion (-1, 0, or 1)
|
||||||
|
* @returns Adjusted bitfield
|
||||||
|
*/
|
||||||
|
export function adjustDaysForDayShift(bitfield: number, dayShift: -1 | 0 | 1): number {
|
||||||
|
if (dayShift === 1) return shiftDaysForward(bitfield)
|
||||||
|
if (dayShift === -1) return shiftDaysBackward(bitfield)
|
||||||
|
return bitfield
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user