diff --git a/app/db/models.py b/app/db/models.py
index 6c66abb..5105e46 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -89,6 +89,20 @@ class SystemLogEntry(Base):
user_id: Mapped[int | None] = mapped_column(ForeignKey("sync_users.id", ondelete="SET NULL"))
+class SchedulerSettings(Base):
+ """Singleton row (id is always 1) holding the admin-configurable day/night
+ sync interval -- editable at runtime via the UI, unlike SYNC_INTERVAL_MINUTES
+ which only seeds this row's initial values on first startup."""
+
+ __tablename__ = "scheduler_settings"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ day_start_hour: Mapped[int] = mapped_column(Integer, nullable=False, default=6)
+ night_start_hour: Mapped[int] = mapped_column(Integer, nullable=False, default=22)
+ day_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
+ night_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
+
+
class SyncRun(Base):
__tablename__ = "sync_runs"
diff --git a/app/db/repositories.py b/app/db/repositories.py
index 2535299..eaba7b7 100644
--- a/app/db/repositories.py
+++ b/app/db/repositories.py
@@ -5,7 +5,18 @@ from sqlalchemy import and_, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
-from app.db.models import Activity, ActivityStatus, SyncRun, SyncRunStatus, SystemLogEntry, SyncUser, utcnow
+from app.db.models import (
+ Activity,
+ ActivityStatus,
+ SchedulerSettings,
+ SyncRun,
+ SyncRunStatus,
+ SystemLogEntry,
+ SyncUser,
+ utcnow,
+)
+
+_SCHEDULER_SETTINGS_ID = 1
@dataclass(frozen=True)
@@ -211,6 +222,29 @@ class SystemLogRepository:
)
+class SchedulerSettingsRepository:
+ def __init__(self, session: Session) -> None:
+ self.session = session
+
+ def get_or_create(self, *, default_minutes: int = 5) -> SchedulerSettings:
+ row = self.session.get(SchedulerSettings, _SCHEDULER_SETTINGS_ID)
+ if row is None:
+ row = SchedulerSettings(
+ id=_SCHEDULER_SETTINGS_ID,
+ day_interval_minutes=default_minutes,
+ night_interval_minutes=default_minutes,
+ )
+ self.session.add(row)
+ self.session.commit()
+ return row
+
+ def update(self, row: SchedulerSettings, **values) -> SchedulerSettings:
+ for key, value in values.items():
+ setattr(row, key, value)
+ self.session.commit()
+ return row
+
+
class SyncRunRepository:
def __init__(self, session: Session) -> None:
self.session = session
diff --git a/app/main.py b/app/main.py
index 8288275..1684078 100644
--- a/app/main.py
+++ b/app/main.py
@@ -13,7 +13,7 @@ from app.mywhoosh.client import MyWhooshClient
from app.notifications.emailer import EmailNotifier
from app.security.credentials import CredentialCipher
from app.sync.manager import SyncManager
-from app.sync.scheduler import SyncScheduler
+from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler
from app.web.account import router as account_router
from app.web.operations import router as operations_router
from app.web.routes import router as web_router
@@ -55,7 +55,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
)
app.state.sync_manager = sync_manager
- scheduler = SyncScheduler(sync_manager, interval_seconds=resolved.sync_interval_minutes * 60)
+ interval_provider = DayNightIntervalProvider(
+ app.state.session_factory, default_minutes=resolved.sync_interval_minutes
+ )
+ scheduler = SyncScheduler(
+ sync_manager,
+ interval_seconds=resolved.sync_interval_minutes * 60,
+ interval_provider=interval_provider,
+ )
app.state.scheduler = scheduler
await scheduler.start()
diff --git a/app/sync/scheduler.py b/app/sync/scheduler.py
index b87882e..1459857 100644
--- a/app/sync/scheduler.py
+++ b/app/sync/scheduler.py
@@ -1,22 +1,80 @@
import asyncio
import logging
from datetime import datetime, timedelta, timezone
+from typing import Callable
+from app.db.repositories import SchedulerSettingsRepository
from app.sync.manager import SyncAlreadyRunning
logger = logging.getLogger(__name__)
+def is_daytime(hour: int, day_start_hour: int, night_start_hour: int) -> bool:
+ """True if `hour` falls within [day_start_hour, night_start_hour),
+ handling a day period that wraps past midnight (e.g. day_start=20,
+ night_start=6). Equal start hours are treated as "always day"."""
+ if day_start_hour == night_start_hour:
+ return True
+ if day_start_hour < night_start_hour:
+ return day_start_hour <= hour < night_start_hour
+ return hour >= day_start_hour or hour < night_start_hour
+
+
+class DayNightIntervalProvider:
+ """Reads the admin-configurable day/night interval from the DB on every
+ call, so a change made via the UI takes effect on the scheduler's very
+ next tick without an app restart."""
+
+ def __init__(self, session_factory, *, default_minutes: int, now: Callable[[], datetime] | None = None) -> None:
+ self.session_factory = session_factory
+ self.default_minutes = default_minutes
+ self._now = now or datetime.now
+
+ def __call__(self) -> float:
+ with self.session_factory() as session:
+ row = SchedulerSettingsRepository(session).get_or_create(default_minutes=self.default_minutes)
+ day_start_hour = row.day_start_hour
+ night_start_hour = row.night_start_hour
+ day_interval_minutes = row.day_interval_minutes
+ night_interval_minutes = row.night_interval_minutes
+
+ hour = self._now().hour
+ minutes = (
+ day_interval_minutes
+ if is_daytime(hour, day_start_hour, night_start_hour)
+ else night_interval_minutes
+ )
+ return minutes * 60
+
+
class SyncScheduler:
- def __init__(self, manager, *, interval_seconds: float) -> None:
+ def __init__(
+ self,
+ manager,
+ *,
+ interval_seconds: float,
+ interval_provider: Callable[[], float] | None = None,
+ ) -> None:
self.manager = manager
self.interval_seconds = interval_seconds
+ self.interval_provider = interval_provider
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
self.last_tick = None
self.next_tick = None
- async def run_once(self) -> None:
+ def _current_interval(self) -> float:
+ if self.interval_provider is None:
+ return self.interval_seconds
+ try:
+ return float(self.interval_provider())
+ except Exception:
+ logger.warning("interval_provider failed, falling back to default interval", exc_info=True)
+ return self.interval_seconds
+
+ async def run_once(self, interval: float | None = None) -> None:
+ if interval is None:
+ interval = self._current_interval()
self.last_tick = datetime.now(timezone.utc)
try:
results = await self.manager.sync_all_enabled()
@@ -36,13 +94,14 @@ class SyncScheduler:
except Exception:
logger.exception("sync_all_enabled failed during scheduled tick")
finally:
- self.next_tick = datetime.now(timezone.utc) + timedelta(seconds=self.interval_seconds)
+ self.next_tick = datetime.now(timezone.utc) + timedelta(seconds=interval)
async def _run(self) -> None:
while not self._stop.is_set():
- await self.run_once()
+ interval = self._current_interval()
+ await self.run_once(interval)
try:
- await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds)
+ await asyncio.wait_for(self._stop.wait(), timeout=interval)
except TimeoutError:
pass
diff --git a/app/web/operations.py b/app/web/operations.py
index 507ba2c..5099aaf 100644
--- a/app/web/operations.py
+++ b/app/web/operations.py
@@ -1,11 +1,11 @@
from fastapi import APIRouter, Form, HTTPException, Request
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import func, select
from app.auth.admin import require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.models import Activity
-from app.db.repositories import ActivityRepository, SystemLogRepository, UserRepository
+from app.db.repositories import ActivityRepository, SchedulerSettingsRepository, SystemLogRepository, UserRepository
from app.sync.manager import SyncAlreadyRunning
from app.web.routes import templates
@@ -104,13 +104,45 @@ def system_page(request: Request):
user_count = len(UserRepository(session).list_all())
activity_count = session.scalar(select(func.count()).select_from(Activity)) or 0
log_entries = SystemLogRepository(session).list_recent(limit=50)
+ scheduler_settings = SchedulerSettingsRepository(session).get_or_create(
+ default_minutes=settings.sync_interval_minutes
+ )
return templates.TemplateResponse(request, "system.html", {
"csrf_token": ensure_csrf_token(request),
"app_version": APP_VERSION,
- "sync_interval_minutes": settings.sync_interval_minutes,
"last_tick": scheduler.last_tick,
"next_tick": scheduler.next_tick,
"user_count": user_count,
"activity_count": activity_count,
"log_entries": log_entries,
+ "scheduler_settings": scheduler_settings,
})
+
+
+@router.post("/system/scheduler-settings")
+def update_scheduler_settings(
+ request: Request,
+ csrf_token: str = Form(...),
+ day_start_hour: int = Form(...),
+ night_start_hour: int = Form(...),
+ day_interval_minutes: int = Form(...),
+ night_interval_minutes: int = Form(...),
+):
+ require_admin(request)
+ validate_csrf(request, csrf_token)
+ if not (0 <= day_start_hour <= 23) or not (0 <= night_start_hour <= 23):
+ raise HTTPException(status_code=400, detail="Hours must be between 0 and 23")
+ if day_interval_minutes < 1 or night_interval_minutes < 1:
+ raise HTTPException(status_code=400, detail="Intervals must be at least 1 minute")
+ settings = request.app.state.settings
+ with request.app.state.session_factory() as session:
+ repository = SchedulerSettingsRepository(session)
+ row = repository.get_or_create(default_minutes=settings.sync_interval_minutes)
+ repository.update(
+ row,
+ day_start_hour=day_start_hour,
+ night_start_hour=night_start_hour,
+ day_interval_minutes=day_interval_minutes,
+ night_interval_minutes=night_interval_minutes,
+ )
+ return RedirectResponse("/system", status_code=303)
diff --git a/app/web/templates/system.html b/app/web/templates/system.html
index 94442ff..a0b8bd4 100644
--- a/app/web/templates/system.html
+++ b/app/web/templates/system.html
@@ -10,9 +10,6 @@
Application version
{{ app_version }}
- Sync interval (minutes)
- {{ sync_interval_minutes }}
-
Last scheduler tick
{{ last_tick or "-" }}
@@ -32,6 +29,31 @@
+Sync scheduling
+
+
+
+
System log
{% if log_entries %}
diff --git a/tests/conftest.py b/tests/conftest.py
index 686b514..d32564b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -9,7 +9,13 @@ from sqlalchemy.pool import StaticPool
from app.config import Settings
from app.db.models import Activity, Base, HealthState
-from app.db.repositories import ActivityRepository, SyncRunRepository, SystemLogRepository, UserRepository
+from app.db.repositories import (
+ ActivityRepository,
+ SchedulerSettingsRepository,
+ SyncRunRepository,
+ SystemLogRepository,
+ UserRepository,
+)
from app.main import create_app
@@ -44,6 +50,11 @@ def sync_run_repository(db_session: Session) -> SyncRunRepository:
return SyncRunRepository(db_session)
+@pytest.fixture
+def scheduler_settings_repository(db_session: Session) -> SchedulerSettingsRepository:
+ return SchedulerSettingsRepository(db_session)
+
+
@pytest.fixture
def system_log_repository(db_session: Session) -> SystemLogRepository:
return SystemLogRepository(db_session)
diff --git a/tests/db/test_repositories.py b/tests/db/test_repositories.py
index 9d4d67e..370049c 100644
--- a/tests/db/test_repositories.py
+++ b/tests/db/test_repositories.py
@@ -93,3 +93,39 @@ def test_system_log_respects_limit(system_log_repository) -> None:
entries = system_log_repository.list_recent(limit=2)
assert len(entries) == 2
+
+
+def test_scheduler_settings_get_or_create_seeds_defaults(scheduler_settings_repository) -> None:
+ row = scheduler_settings_repository.get_or_create(default_minutes=7)
+
+ assert row.day_interval_minutes == 7
+ assert row.night_interval_minutes == 7
+ assert row.day_start_hour == 6
+ assert row.night_start_hour == 22
+
+
+def test_scheduler_settings_get_or_create_is_idempotent_after_update(scheduler_settings_repository) -> None:
+ row = scheduler_settings_repository.get_or_create(default_minutes=5)
+ scheduler_settings_repository.update(row, day_interval_minutes=15)
+
+ reloaded = scheduler_settings_repository.get_or_create(default_minutes=5)
+
+ assert reloaded.day_interval_minutes == 15
+
+
+def test_scheduler_settings_update_persists_all_fields(scheduler_settings_repository) -> None:
+ row = scheduler_settings_repository.get_or_create(default_minutes=5)
+
+ scheduler_settings_repository.update(
+ row,
+ day_start_hour=8,
+ night_start_hour=20,
+ day_interval_minutes=10,
+ night_interval_minutes=45,
+ )
+
+ reloaded = scheduler_settings_repository.get_or_create(default_minutes=5)
+ assert reloaded.day_start_hour == 8
+ assert reloaded.night_start_hour == 20
+ assert reloaded.day_interval_minutes == 10
+ assert reloaded.night_interval_minutes == 45
diff --git a/tests/sync/test_scheduler.py b/tests/sync/test_scheduler.py
index 9843ba9..546cfd3 100644
--- a/tests/sync/test_scheduler.py
+++ b/tests/sync/test_scheduler.py
@@ -1,8 +1,14 @@
import asyncio
+from datetime import datetime
import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.pool import StaticPool
-from app.sync.scheduler import SyncScheduler
+from app.db.models import Base
+from app.db.repositories import SchedulerSettingsRepository
+from app.db.session import create_session_factory
+from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler, is_daytime
class FakeSyncManager:
@@ -40,3 +46,91 @@ async def test_scheduler_stop_cancels_the_loop() -> None:
calls_after_stop = fake.calls
await asyncio.sleep(0.05)
assert fake.calls == calls_after_stop
+
+
+@pytest.mark.parametrize(
+ ("hour", "day_start", "night_start", "expected"),
+ [
+ (6, 6, 22, True),
+ (21, 6, 22, True),
+ (5, 6, 22, False),
+ (22, 6, 22, False),
+ (0, 6, 22, False),
+ # wraps past midnight: day period is [20, 6)
+ (23, 20, 6, True),
+ (2, 20, 6, True),
+ (10, 20, 6, False),
+ (20, 20, 6, True),
+ (6, 20, 6, False),
+ # degenerate: identical start hours means always day
+ (13, 9, 9, True),
+ ],
+)
+def test_is_daytime(hour, day_start, night_start, expected) -> None:
+ assert is_daytime(hour, day_start, night_start) is expected
+
+
+@pytest.fixture
+def db_session_factory():
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
+ Base.metadata.create_all(engine)
+ factory = create_session_factory(engine)
+ try:
+ yield factory
+ finally:
+ engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_interval_provider_uses_day_interval_during_the_day(db_session_factory) -> None:
+ provider = DayNightIntervalProvider(
+ db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0)
+ )
+ assert provider() == 5 * 60
+
+
+@pytest.mark.asyncio
+async def test_interval_provider_uses_night_interval_at_night(db_session_factory) -> None:
+ with db_session_factory() as session:
+ repo = SchedulerSettingsRepository(session)
+ row = repo.get_or_create(default_minutes=5)
+ repo.update(row, night_interval_minutes=30)
+
+ provider = DayNightIntervalProvider(
+ db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 23, 0)
+ )
+ assert provider() == 30 * 60
+
+
+@pytest.mark.asyncio
+async def test_scheduler_uses_interval_provider_for_next_tick(db_session_factory) -> None:
+ with db_session_factory() as session:
+ repo = SchedulerSettingsRepository(session)
+ row = repo.get_or_create(default_minutes=5)
+ repo.update(row, day_interval_minutes=1, night_interval_minutes=1)
+
+ provider = DayNightIntervalProvider(
+ db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0)
+ )
+ fake = FakeSyncManager()
+ scheduler = SyncScheduler(fake, interval_seconds=999, interval_provider=provider)
+
+ await scheduler.run_once()
+
+ expected_seconds = 60 # day_interval_minutes=1 -> 60s, not the unrelated interval_seconds=999 fallback
+ delta = (scheduler.next_tick - scheduler.last_tick).total_seconds()
+ assert abs(delta - expected_seconds) < 1
+
+
+@pytest.mark.asyncio
+async def test_scheduler_falls_back_when_interval_provider_raises() -> None:
+ def broken_provider():
+ raise RuntimeError("db unavailable")
+
+ fake = FakeSyncManager()
+ scheduler = SyncScheduler(fake, interval_seconds=42, interval_provider=broken_provider)
+
+ await scheduler.run_once()
+
+ delta = (scheduler.next_tick - scheduler.last_tick).total_seconds()
+ assert abs(delta - 42) < 1
diff --git a/tests/web/test_operations.py b/tests/web/test_operations.py
index 1e3f527..3ea464b 100644
--- a/tests/web/test_operations.py
+++ b/tests/web/test_operations.py
@@ -1,6 +1,6 @@
from fastapi.testclient import TestClient
-from app.db.repositories import SystemLogRepository
+from app.db.repositories import SchedulerSettingsRepository, SystemLogRepository
def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manager) -> None:
@@ -89,3 +89,115 @@ def test_system_page_shows_recorded_log_entries(app, authenticated_client) -> No
assert response.status_code == 200
assert "email_notification" in response.text
assert "SMTP timeout" in response.text
+
+
+def _extract_csrf(html: str) -> str:
+ marker = 'name="csrf_token" value="'
+ start = html.index(marker) + len(marker)
+ end = html.index('"', start)
+ return html[start:end]
+
+
+def test_scheduler_settings_page_prefills_defaults(app, authenticated_client) -> None:
+ app.state.scheduler = _FakeScheduler()
+
+ response = authenticated_client.get("/system")
+
+ assert response.status_code == 200
+ assert 'id="day_start_hour"' in response.text
+ assert 'value="6"' in response.text
+ assert 'value="22"' in response.text
+
+
+def test_update_scheduler_settings_persists_and_takes_effect_next_tick(app, authenticated_client) -> None:
+ app.state.scheduler = _FakeScheduler()
+ page = authenticated_client.get("/system")
+ csrf = _extract_csrf(page.text)
+
+ response = authenticated_client.post(
+ "/system/scheduler-settings",
+ data={
+ "csrf_token": csrf,
+ "day_start_hour": "8",
+ "night_start_hour": "20",
+ "day_interval_minutes": "3",
+ "night_interval_minutes": "45",
+ },
+ follow_redirects=False,
+ )
+ assert response.status_code == 303
+ assert response.headers["location"] == "/system"
+
+ with app.state.session_factory() as session:
+ row = SchedulerSettingsRepository(session).get_or_create(default_minutes=5)
+ assert row.day_start_hour == 8
+ assert row.night_start_hour == 20
+ assert row.day_interval_minutes == 3
+ assert row.night_interval_minutes == 45
+
+
+def test_update_scheduler_settings_rejects_out_of_range_hour(app, authenticated_client) -> None:
+ app.state.scheduler = _FakeScheduler()
+ page = authenticated_client.get("/system")
+ csrf = _extract_csrf(page.text)
+
+ response = authenticated_client.post(
+ "/system/scheduler-settings",
+ data={
+ "csrf_token": csrf,
+ "day_start_hour": "24",
+ "night_start_hour": "22",
+ "day_interval_minutes": "5",
+ "night_interval_minutes": "5",
+ },
+ )
+ assert response.status_code == 400
+
+
+def test_update_scheduler_settings_rejects_non_positive_interval(app, authenticated_client) -> None:
+ app.state.scheduler = _FakeScheduler()
+ page = authenticated_client.get("/system")
+ csrf = _extract_csrf(page.text)
+
+ response = authenticated_client.post(
+ "/system/scheduler-settings",
+ data={
+ "csrf_token": csrf,
+ "day_start_hour": "6",
+ "night_start_hour": "22",
+ "day_interval_minutes": "0",
+ "night_interval_minutes": "5",
+ },
+ )
+ assert response.status_code == 400
+
+
+def test_update_scheduler_settings_requires_admin(client: TestClient) -> None:
+ response = client.post(
+ "/system/scheduler-settings",
+ data={
+ "csrf_token": "whatever",
+ "day_start_hour": "6",
+ "night_start_hour": "22",
+ "day_interval_minutes": "5",
+ "night_interval_minutes": "5",
+ },
+ follow_redirects=False,
+ )
+ assert response.status_code == 303
+ assert response.headers["location"] == "/login"
+
+
+def test_update_scheduler_settings_rejects_invalid_csrf(app, authenticated_client) -> None:
+ app.state.scheduler = _FakeScheduler()
+ response = authenticated_client.post(
+ "/system/scheduler-settings",
+ data={
+ "csrf_token": "invalid-token",
+ "day_start_hour": "6",
+ "night_start_hour": "22",
+ "day_interval_minutes": "5",
+ "night_interval_minutes": "5",
+ },
+ )
+ assert response.status_code == 403