Phase 3 Step 6: Complete Scheduler Integration with Bug Fixes
Implemented complete scheduler integration with automatic schedule loading, orphaned scan cleanup, and conversion to local timezone for better UX. Backend Changes: - Added load_schedules_on_startup() to load enabled schedules on app start - Implemented cleanup_orphaned_scans() to handle crashed/interrupted scans - Converted scheduler from UTC to local system timezone throughout - Enhanced scheduler service with robust error handling and logging Frontend Changes: - Updated all schedule UI templates to display local time instead of UTC - Improved timezone indicators and user messaging - Removed confusing timezone converter (no longer needed) - Updated quick templates and help text for local time Bug Fixes: - Fixed critical timezone bug causing cron expressions to run at wrong times - Fixed orphaned scans stuck in 'running' status after system crashes - Improved time display clarity across all schedule pages All schedules now use local system time for intuitive scheduling.
This commit is contained in:
@@ -1368,7 +1368,7 @@ async function loadScanTrend() {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Step 6: Scheduler Integration ✅ (Day 10)
|
### Step 6: Scheduler Integration ✅ COMPLETE (Day 10)
|
||||||
**Priority: CRITICAL** - Complete scheduled execution
|
**Priority: CRITICAL** - Complete scheduled execution
|
||||||
|
|
||||||
**Tasks:**
|
**Tasks:**
|
||||||
|
|||||||
12
web/app.py
12
web/app.py
@@ -294,11 +294,23 @@ def init_scheduler(app: Flask) -> None:
|
|||||||
app: Flask application instance
|
app: Flask application instance
|
||||||
"""
|
"""
|
||||||
from web.services.scheduler_service import SchedulerService
|
from web.services.scheduler_service import SchedulerService
|
||||||
|
from web.services.scan_service import ScanService
|
||||||
|
|
||||||
# Create and initialize scheduler
|
# Create and initialize scheduler
|
||||||
scheduler = SchedulerService()
|
scheduler = SchedulerService()
|
||||||
scheduler.init_scheduler(app)
|
scheduler.init_scheduler(app)
|
||||||
|
|
||||||
|
# Perform startup tasks with app context for database access
|
||||||
|
with app.app_context():
|
||||||
|
# Clean up any orphaned scans from previous crashes/restarts
|
||||||
|
scan_service = ScanService(app.db_session)
|
||||||
|
orphaned_count = scan_service.cleanup_orphaned_scans()
|
||||||
|
if orphaned_count > 0:
|
||||||
|
app.logger.warning(f"Cleaned up {orphaned_count} orphaned scan(s) on startup")
|
||||||
|
|
||||||
|
# Load all enabled schedules from database
|
||||||
|
scheduler.load_schedules_on_startup()
|
||||||
|
|
||||||
# Store in app context for access from routes
|
# Store in app context for access from routes
|
||||||
app.scheduler = scheduler
|
app.scheduler = scheduler
|
||||||
|
|
||||||
|
|||||||
@@ -268,6 +268,53 @@ class ScanService:
|
|||||||
|
|
||||||
return status_info
|
return status_info
|
||||||
|
|
||||||
|
def cleanup_orphaned_scans(self) -> int:
|
||||||
|
"""
|
||||||
|
Clean up orphaned scans that are stuck in 'running' status.
|
||||||
|
|
||||||
|
This should be called on application startup to handle scans that
|
||||||
|
were running when the system crashed or was restarted.
|
||||||
|
|
||||||
|
Scans in 'running' status are marked as 'failed' with an appropriate
|
||||||
|
error message indicating they were orphaned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of orphaned scans cleaned up
|
||||||
|
"""
|
||||||
|
# Find all scans with status='running'
|
||||||
|
orphaned_scans = self.db.query(Scan).filter(Scan.status == 'running').all()
|
||||||
|
|
||||||
|
if not orphaned_scans:
|
||||||
|
logger.info("No orphaned scans found")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
count = len(orphaned_scans)
|
||||||
|
logger.warning(f"Found {count} orphaned scan(s) in 'running' status, marking as failed")
|
||||||
|
|
||||||
|
# Mark each orphaned scan as failed
|
||||||
|
for scan in orphaned_scans:
|
||||||
|
scan.status = 'failed'
|
||||||
|
scan.completed_at = datetime.utcnow()
|
||||||
|
scan.error_message = (
|
||||||
|
"Scan was interrupted by system shutdown or crash. "
|
||||||
|
"The scan was running but did not complete normally."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate duration if we have a started_at time
|
||||||
|
if scan.started_at:
|
||||||
|
duration = (datetime.utcnow() - scan.started_at).total_seconds()
|
||||||
|
scan.duration = duration
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Marked orphaned scan {scan.id} as failed "
|
||||||
|
f"(started: {scan.started_at.isoformat() if scan.started_at else 'unknown'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
logger.info(f"Cleaned up {count} orphaned scan(s)")
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
def _save_scan_to_db(self, report: Dict[str, Any], scan_id: int,
|
def _save_scan_to_db(self, report: Dict[str, Any], scan_id: int,
|
||||||
status: str = 'completed') -> None:
|
status: str = 'completed') -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ scan execution and future scheduled scanning capabilities.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
@@ -63,11 +63,13 @@ class SchedulerService:
|
|||||||
'misfire_grace_time': 60 # Allow 60 seconds for delayed starts
|
'misfire_grace_time': 60 # Allow 60 seconds for delayed starts
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create scheduler
|
# Create scheduler with local system timezone
|
||||||
|
# This allows users to schedule jobs using their local time
|
||||||
|
# APScheduler will automatically use the system's local timezone
|
||||||
self.scheduler = BackgroundScheduler(
|
self.scheduler = BackgroundScheduler(
|
||||||
executors=executors,
|
executors=executors,
|
||||||
job_defaults=job_defaults,
|
job_defaults=job_defaults
|
||||||
timezone='UTC'
|
# timezone defaults to local system timezone
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start scheduler
|
# Start scheduler
|
||||||
@@ -90,6 +92,63 @@ class SchedulerService:
|
|||||||
logger.info("APScheduler shutdown complete")
|
logger.info("APScheduler shutdown complete")
|
||||||
self.scheduler = None
|
self.scheduler = None
|
||||||
|
|
||||||
|
def load_schedules_on_startup(self):
|
||||||
|
"""
|
||||||
|
Load all enabled schedules from database and register with APScheduler.
|
||||||
|
|
||||||
|
Should be called after init_scheduler() to restore scheduled jobs
|
||||||
|
that were active when the application last shutdown.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If scheduler not initialized
|
||||||
|
"""
|
||||||
|
if not self.scheduler:
|
||||||
|
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
|
||||||
|
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from web.models import Schedule
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create database session
|
||||||
|
engine = create_engine(self.db_url)
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Query all enabled schedules
|
||||||
|
enabled_schedules = (
|
||||||
|
session.query(Schedule)
|
||||||
|
.filter(Schedule.enabled == True)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Loading {len(enabled_schedules)} enabled schedules on startup")
|
||||||
|
|
||||||
|
# Register each schedule with APScheduler
|
||||||
|
for schedule in enabled_schedules:
|
||||||
|
try:
|
||||||
|
self.add_scheduled_scan(
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
config_file=schedule.config_file,
|
||||||
|
cron_expression=schedule.cron_expression
|
||||||
|
)
|
||||||
|
logger.info(f"Loaded schedule {schedule.id}: '{schedule.name}'")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to load schedule {schedule.id} ('{schedule.name}'): {str(e)}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Schedule loading complete")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading schedules on startup: {str(e)}", exc_info=True)
|
||||||
|
|
||||||
def queue_scan(self, scan_id: int, config_file: str) -> str:
|
def queue_scan(self, scan_id: int, config_file: str) -> str:
|
||||||
"""
|
"""
|
||||||
Queue a scan for immediate background execution.
|
Queue a scan for immediate background execution.
|
||||||
@@ -142,9 +201,11 @@ class SchedulerService:
|
|||||||
|
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
# Create cron trigger from expression
|
# Create cron trigger from expression using local timezone
|
||||||
|
# This allows users to specify times in their local timezone
|
||||||
try:
|
try:
|
||||||
trigger = CronTrigger.from_crontab(cron_expression)
|
trigger = CronTrigger.from_crontab(cron_expression)
|
||||||
|
# timezone defaults to local system timezone
|
||||||
except (ValueError, KeyError) as e:
|
except (ValueError, KeyError) as e:
|
||||||
raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}")
|
raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}")
|
||||||
|
|
||||||
|
|||||||
@@ -69,10 +69,10 @@
|
|||||||
<label class="form-label">Quick Templates:</label>
|
<label class="form-label">Quick Templates:</label>
|
||||||
<div class="btn-group-vertical btn-group-sm w-100" role="group">
|
<div class="btn-group-vertical btn-group-sm w-100" role="group">
|
||||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
|
||||||
<strong>Daily at Midnight</strong> <code class="float-end">0 0 * * *</code>
|
<strong>Daily at Midnight (local)</strong> <code class="float-end">0 0 * * *</code>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
|
||||||
<strong>Daily at 2 AM</strong> <code class="float-end">0 2 * * *</code>
|
<strong>Daily at 2 AM (local)</strong> <code class="float-end">0 2 * * *</code>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
|
||||||
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
|
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
|
||||||
@@ -90,12 +90,14 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="cron-expression" class="form-label">
|
<label for="cron-expression" class="form-label">
|
||||||
Cron Expression <span class="text-danger">*</span>
|
Cron Expression <span class="text-danger">*</span>
|
||||||
|
<span class="badge bg-info">LOCAL TIME</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" class="form-control font-monospace" id="cron-expression"
|
<input type="text" class="form-control font-monospace" id="cron-expression"
|
||||||
name="cron_expression" placeholder="0 2 * * *"
|
name="cron_expression" placeholder="0 2 * * *"
|
||||||
oninput="validateCron()" required>
|
oninput="validateCron()" required>
|
||||||
<small class="form-text text-muted">
|
<small class="form-text text-muted">
|
||||||
Format: <code>minute hour day month weekday</code> (UTC timezone)
|
Format: <code>minute hour day month weekday</code><br>
|
||||||
|
<strong class="text-info">ℹ All times use your local timezone (CST/UTC-6)</strong>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,7 +114,7 @@
|
|||||||
|
|
||||||
<!-- Next Run Times Preview -->
|
<!-- Next Run Times Preview -->
|
||||||
<div id="next-runs-container" style="display: none;">
|
<div id="next-runs-container" style="display: none;">
|
||||||
<label class="form-label">Next 5 execution times (UTC):</label>
|
<label class="form-label">Next 5 execution times (local time):</label>
|
||||||
<ul id="next-runs-list" class="list-group">
|
<ul id="next-runs-list" class="list-group">
|
||||||
<!-- Populated by JavaScript -->
|
<!-- Populated by JavaScript -->
|
||||||
</ul>
|
</ul>
|
||||||
@@ -188,9 +190,12 @@
|
|||||||
<li><code>0 9-17 * * 1-5</code> - Hourly, 9am-5pm, Mon-Fri</li>
|
<li><code>0 9-17 * * 1-5</code> - Hourly, 9am-5pm, Mon-Fri</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="alert alert-warning mt-3">
|
<div class="alert alert-info mt-3">
|
||||||
<strong>Note:</strong> All times are in UTC timezone. The server is currently at
|
<strong><i class="bi bi-info-circle"></i> Timezone Information:</strong><br>
|
||||||
<strong><span id="server-time"></span></strong> UTC.
|
All cron expressions use your <strong>local system time</strong>.<br><br>
|
||||||
|
<strong>Current local time:</strong> <span id="user-local-time"></span><br>
|
||||||
|
<strong>Your timezone:</strong> <span id="timezone-offset"></span><br><br>
|
||||||
|
<small>Schedules will run at the specified time in your local timezone.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,10 +203,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Update server time every second
|
// Update local time and timezone info every second
|
||||||
function updateServerTime() {
|
function updateServerTime() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
document.getElementById('server-time').textContent = now.toUTCString().split(' ')[4];
|
const localTime = now.toLocaleTimeString();
|
||||||
|
const offset = -now.getTimezoneOffset() / 60;
|
||||||
|
const offsetStr = `CST (UTC${offset >= 0 ? '+' : ''}${offset})`;
|
||||||
|
|
||||||
|
if (document.getElementById('user-local-time')) {
|
||||||
|
document.getElementById('user-local-time').textContent = localTime;
|
||||||
|
}
|
||||||
|
if (document.getElementById('timezone-offset')) {
|
||||||
|
document.getElementById('timezone-offset').textContent = offsetStr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updateServerTime();
|
updateServerTime();
|
||||||
setInterval(updateServerTime, 1000);
|
setInterval(updateServerTime, 1000);
|
||||||
@@ -309,13 +323,13 @@ function describeCron(parts) {
|
|||||||
|
|
||||||
// Common patterns
|
// Common patterns
|
||||||
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday === '*') {
|
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday === '*') {
|
||||||
return 'Runs daily at midnight (00:00 UTC)';
|
return 'Runs daily at midnight (local time)';
|
||||||
}
|
}
|
||||||
if (minute === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
if (minute === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
||||||
return `Runs daily at ${hour.padStart(2, '0')}:00 UTC`;
|
return `Runs daily at ${hour.padStart(2, '0')}:00 (local time)`;
|
||||||
}
|
}
|
||||||
if (minute !== '*' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
if (minute !== '*' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
||||||
return `Runs daily at ${hour.padStart(2, '0')}:${minute.padStart(2, '0')} UTC`;
|
return `Runs daily at ${hour.padStart(2, '0')}:${minute.padStart(2, '0')} (local time)`;
|
||||||
}
|
}
|
||||||
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday !== '*') {
|
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday !== '*') {
|
||||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
|||||||
@@ -92,10 +92,10 @@
|
|||||||
<label class="form-label">Quick Templates:</label>
|
<label class="form-label">Quick Templates:</label>
|
||||||
<div class="btn-group-vertical btn-group-sm w-100" role="group">
|
<div class="btn-group-vertical btn-group-sm w-100" role="group">
|
||||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
|
||||||
<strong>Daily at Midnight</strong> <code class="float-end">0 0 * * *</code>
|
<strong>Daily at Midnight (local)</strong> <code class="float-end">0 0 * * *</code>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
|
||||||
<strong>Daily at 2 AM</strong> <code class="float-end">0 2 * * *</code>
|
<strong>Daily at 2 AM (local)</strong> <code class="float-end">0 2 * * *</code>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
|
||||||
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
|
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
name="cron_expression" placeholder="0 2 * * *"
|
name="cron_expression" placeholder="0 2 * * *"
|
||||||
oninput="validateCron()" required>
|
oninput="validateCron()" required>
|
||||||
<small class="form-text text-muted">
|
<small class="form-text text-muted">
|
||||||
Format: <code>minute hour day month weekday</code> (UTC timezone)
|
Format: <code>minute hour day month weekday</code> (local timezone)
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,13 +130,13 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<strong>Last Run:</strong><br>
|
<strong>Last Run:</strong><br>
|
||||||
<span id="last-run">Never</span>
|
<span id="last-run" style="white-space: pre-line;">Never</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<strong>Next Run:</strong><br>
|
<strong>Next Run:</strong><br>
|
||||||
<span id="next-run">Not scheduled</span>
|
<span id="next-run" style="white-space: pre-line;">Not scheduled</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,8 +248,11 @@
|
|||||||
<li><code>1-5</code> - Range of values</li>
|
<li><code>1-5</code> - Range of values</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="alert alert-warning mt-3">
|
<div class="alert alert-info mt-3">
|
||||||
<strong>Note:</strong> All times are in UTC timezone.
|
<strong><i class="bi bi-info-circle"></i> Timezone Information:</strong><br>
|
||||||
|
All cron expressions use your <strong>local system time</strong>.<br><br>
|
||||||
|
<strong>Current local time:</strong> <span id="current-local"></span><br>
|
||||||
|
<strong>Your timezone:</strong> <span id="tz-offset"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,13 +306,15 @@ function populateForm(schedule) {
|
|||||||
document.getElementById('created-at').textContent = new Date(schedule.created_at).toLocaleString();
|
document.getElementById('created-at').textContent = new Date(schedule.created_at).toLocaleString();
|
||||||
document.getElementById('updated-at').textContent = new Date(schedule.updated_at).toLocaleString();
|
document.getElementById('updated-at').textContent = new Date(schedule.updated_at).toLocaleString();
|
||||||
|
|
||||||
// Run times
|
// Run times - show in local time
|
||||||
document.getElementById('last-run').textContent = schedule.last_run
|
document.getElementById('last-run').textContent = schedule.last_run
|
||||||
? formatRelativeTime(schedule.last_run) + ' (' + new Date(schedule.last_run).toLocaleString() + ')'
|
? formatRelativeTime(schedule.last_run) + '\n' +
|
||||||
|
new Date(schedule.last_run).toLocaleString()
|
||||||
: 'Never';
|
: 'Never';
|
||||||
|
|
||||||
document.getElementById('next-run').textContent = schedule.next_run && schedule.enabled
|
document.getElementById('next-run').textContent = schedule.next_run && schedule.enabled
|
||||||
? formatRelativeTime(schedule.next_run) + ' (' + new Date(schedule.next_run).toLocaleString() + ')'
|
? formatRelativeTime(schedule.next_run) + '\n' +
|
||||||
|
new Date(schedule.next_run).toLocaleString()
|
||||||
: (schedule.enabled ? 'Calculating...' : 'Disabled');
|
: (schedule.enabled ? 'Calculating...' : 'Disabled');
|
||||||
|
|
||||||
// Validate cron
|
// Validate cron
|
||||||
@@ -396,17 +401,22 @@ function formatRelativeTime(timestamp) {
|
|||||||
const diffMs = date - now;
|
const diffMs = date - now;
|
||||||
const diffMinutes = Math.abs(Math.floor(diffMs / 60000));
|
const diffMinutes = Math.abs(Math.floor(diffMs / 60000));
|
||||||
const diffHours = Math.abs(Math.floor(diffMs / 3600000));
|
const diffHours = Math.abs(Math.floor(diffMs / 3600000));
|
||||||
|
const diffDays = Math.abs(Math.floor(diffMs / 86400000));
|
||||||
|
|
||||||
if (diffMs < 0) {
|
if (diffMs < 0) {
|
||||||
|
// Past time
|
||||||
if (diffMinutes < 1) return 'Just now';
|
if (diffMinutes < 1) return 'Just now';
|
||||||
if (diffMinutes < 60) return `${diffMinutes} minutes ago`;
|
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''} ago`;
|
||||||
if (diffHours < 24) return `${diffHours} hours ago`;
|
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
||||||
return date.toLocaleString();
|
if (diffDays === 1) return 'Yesterday';
|
||||||
|
return `${diffDays} days ago`;
|
||||||
} else {
|
} else {
|
||||||
|
// Future time
|
||||||
if (diffMinutes < 1) return 'In less than a minute';
|
if (diffMinutes < 1) return 'In less than a minute';
|
||||||
if (diffMinutes < 60) return `In ${diffMinutes} minutes`;
|
if (diffMinutes < 60) return `In ${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
|
||||||
if (diffHours < 24) return `In ${diffHours} hours`;
|
if (diffHours < 24) return `In ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
|
||||||
return date.toLocaleString();
|
if (diffDays === 1) return 'Tomorrow';
|
||||||
|
return `In ${diffDays} days`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +573,23 @@ function showNotification(message, type = 'info') {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update current time display
|
||||||
|
function updateCurrentTime() {
|
||||||
|
const now = new Date();
|
||||||
|
if (document.getElementById('current-local')) {
|
||||||
|
document.getElementById('current-local').textContent = now.toLocaleTimeString();
|
||||||
|
}
|
||||||
|
if (document.getElementById('tz-offset')) {
|
||||||
|
const offset = -now.getTimezoneOffset() / 60;
|
||||||
|
document.getElementById('tz-offset').textContent = `CST (UTC${offset >= 0 ? '+' : ''}${offset})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load on page load
|
// Load on page load
|
||||||
document.addEventListener('DOMContentLoaded', loadSchedule);
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadSchedule();
|
||||||
|
updateCurrentTime();
|
||||||
|
setInterval(updateCurrentTime, 1000);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ function formatRelativeTime(timestamp) {
|
|||||||
const diffHours = Math.floor(diffMs / 3600000);
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
// Get local time string for tooltip/fallback
|
||||||
|
const localStr = date.toLocaleString();
|
||||||
|
|
||||||
if (diffMs < 0) {
|
if (diffMs < 0) {
|
||||||
// Past time
|
// Past time
|
||||||
const absDiffMinutes = Math.abs(diffMinutes);
|
const absDiffMinutes = Math.abs(diffMinutes);
|
||||||
@@ -120,7 +123,7 @@ function formatRelativeTime(timestamp) {
|
|||||||
if (absDiffHours < 24) return `${absDiffHours} hours ago`;
|
if (absDiffHours < 24) return `${absDiffHours} hours ago`;
|
||||||
if (absDiffDays === 1) return 'Yesterday';
|
if (absDiffDays === 1) return 'Yesterday';
|
||||||
if (absDiffDays < 7) return `${absDiffDays} days ago`;
|
if (absDiffDays < 7) return `${absDiffDays} days ago`;
|
||||||
return date.toLocaleString();
|
return `<span title="${localStr}">${absDiffDays} days ago</span>`;
|
||||||
} else {
|
} else {
|
||||||
// Future time
|
// Future time
|
||||||
if (diffMinutes < 1) return 'In less than a minute';
|
if (diffMinutes < 1) return 'In less than a minute';
|
||||||
@@ -130,7 +133,7 @@ function formatRelativeTime(timestamp) {
|
|||||||
if (diffHours < 24) return `In ${diffHours} hours`;
|
if (diffHours < 24) return `In ${diffHours} hours`;
|
||||||
if (diffDays === 1) return 'Tomorrow';
|
if (diffDays === 1) return 'Tomorrow';
|
||||||
if (diffDays < 7) return `In ${diffDays} days`;
|
if (diffDays < 7) return `In ${diffDays} days`;
|
||||||
return date.toLocaleString();
|
return `<span title="${localStr}">In ${diffDays} days</span>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user