From b421664bd6ab6dfd5a1bddbf615abcdb72b32b34 Mon Sep 17 00:00:00 2001 From: Giorgio Date: Thu, 3 Sep 2026 15:15:24 +0200 Subject: [PATCH 1/3] fix(web): graceful frontend handling when backend goes down Closes #746. When the backend became unavailable while a page stayed open, the service worker returned a plain-text 'Offline' response for /api/* GETs, causing JSON parse errors, the chat widget polling loop spammed failed requests every 30s, and identical error toasts stacked up and evicted the ones carrying Retry/Refresh buttons. - sw.js: synthetic offline API responses are now JSON ({"error": "Offline"}) with application/json content-type - chat widget: check response.ok before parsing, back off polling to 2 minutes after a failure, skip polls while the tab is hidden - error handler: dedupe repeated error-with-retry toasts within the dedup window so the toast with Retry/Refresh buttons stays visible - error handler: remove the offline indicator bar when connectivity is restored (previously it stayed until a full page reload) --- app/static/error-handling-enhanced.js | 14 +++++++ app/static/js/sw.js | 14 ++++++- .../components/persistent_chat_widget.html | 37 ++++++++++++++++--- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/app/static/error-handling-enhanced.js b/app/static/error-handling-enhanced.js index 4efddfcb0..1c061bba0 100644 --- a/app/static/error-handling-enhanced.js +++ b/app/static/error-handling-enhanced.js @@ -174,6 +174,12 @@ class EnhancedErrorHandler { } handleOnline() { + // Remove offline indicator + const indicator = document.getElementById('offline-indicator'); + if (indicator) { + indicator.remove(); + } + // Show online indicator this.showOnlineIndicator(); @@ -470,6 +476,14 @@ class EnhancedErrorHandler { * Show Error with Retry Button */ showErrorWithRetry(message, status, retryCallback) { + // Deduplicate: polling loops can hit the same failure repeatedly (e.g. every + // 30s while the backend is down). Keep the existing toast — it already has + // retry/recovery buttons — instead of stacking identical error cards. + if (this.isDuplicateError(message)) { + console.warn('Duplicate error suppressed:', message); + return null; + } + const recoveryOptions = this.getRecoveryOptions(status); // Create error toast with retry diff --git a/app/static/js/sw.js b/app/static/js/sw.js index a55d40ebb..8f9f1e4ac 100644 --- a/app/static/js/sw.js +++ b/app/static/js/sw.js @@ -59,10 +59,20 @@ async function cacheFirst(request) { } return response; } catch (e) { - return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); + return offlineJsonResponse(); } } +// Synthetic offline response must be JSON — callers parse .json() and a plain-text +// body would throw SyntaxError on top of the connectivity failure. +function offlineJsonResponse() { + return new Response(JSON.stringify({ error: 'Offline' }), { + status: 503, + statusText: 'Service Unavailable', + headers: { 'Content-Type': 'application/json' } + }); +} + async function networkFirstDocument(request) { try { return await fetch(request); @@ -80,7 +90,7 @@ async function networkFirstApi(request) { try { return await fetch(request); } catch (_) { - return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); + return offlineJsonResponse(); } } diff --git a/app/templates/components/persistent_chat_widget.html b/app/templates/components/persistent_chat_widget.html index 734f1c880..8bc1aad76 100644 --- a/app/templates/components/persistent_chat_widget.html +++ b/app/templates/components/persistent_chat_widget.html @@ -221,16 +221,26 @@

now: + stop_at = now + active_timer.stop_timer(end_time=stop_at) + else: # keep at now + active_timer.stop_timer() + + return jsonify({"ok": True, "stopped": True, "time_entry": active_timer.to_dict()}) + except ValueError as e: + db.session.rollback() + return jsonify({"error": str(e)}), 400 + except Exception as e: + db.session.rollback() + current_app.logger.warning("Timer review failed: %s", e) + return jsonify({"error": "Failed to resolve review"}), 500 + + @api_bp.route("/api/tags") @login_required def get_recent_tags(): diff --git a/app/routes/api_v1_time_entries.py b/app/routes/api_v1_time_entries.py index 7f5cb697e..1b552d213 100644 --- a/app/routes/api_v1_time_entries.py +++ b/app/routes/api_v1_time_entries.py @@ -3,7 +3,7 @@ Sub-blueprint for /api/v1/time-entries and /api/v1/timer/*. """ -from flask import Blueprint, g, jsonify, request +from flask import Blueprint, current_app, g, jsonify, request from marshmallow import ValidationError from app.routes.api_v1_common import _parse_date_range, paginate_query @@ -390,6 +390,7 @@ def timer_status(): "timer": active_timer.to_dict(), "idle_timeout_minutes": idle_timeout_minutes, "idle_notified": bool(active_timer.idle_notified_at), + "needs_review": bool(active_timer.idle_flagged_at), } ) @@ -428,6 +429,74 @@ def timer_heartbeat(): return ("", 204) +@api_v1_time_entries_bp.route("/timer/review", methods=["POST"]) +@require_api_token("write:time_entries") +def timer_review(): + """Resolve an idle needs-review flag on the active timer. + + Body: {"action": "trim" | "keep" | "continue", "end_time": optional ISO (for "keep")} + - trim: stop credited to last activity + idle timeout (the old auto-stop behaviour) + - keep: stop at now (or the provided end_time) + - continue: clear the flag and keep the timer running (user is actually working) + """ + from datetime import datetime, timedelta + + from app.models import Settings + from app.models.time_entry import local_now + from app.utils.db import safe_commit + + data = request.get_json(silent=True) or {} + action = (data.get("action") or "").strip().lower() + if action not in ("trim", "keep", "continue"): + return error_response("action must be trim, keep or continue", error_code="invalid_action", status_code=400) + + active_timer = g.api_user.active_timer + if not active_timer: + return error_response("No active timer", error_code="no_active_timer", status_code=400) + + try: + if action == "continue": + active_timer.clear_idle_flags() + active_timer.last_heartbeat_at = local_now() + if not safe_commit("timer_review", {"user_id": g.api_user.id, "entry_id": active_timer.id}): + db.session.rollback() + return error_response("Failed to resolve review", error_code="database_error", status_code=500) + return jsonify({"ok": True, "stopped": False}) + + if action == "keep" and data.get("end_time"): + try: + end_time = datetime.fromisoformat(data["end_time"]) + except ValueError: + return error_response("invalid end_time", error_code="invalid_end_time", status_code=400) + if end_time <= active_timer.start_time: + return error_response( + "end_time must be after start_time", error_code="invalid_end_time", status_code=400 + ) + active_timer.stop_timer(end_time=end_time) + elif action == "trim": + settings = Settings.get_settings() + idle_minutes = max(1, min(480, int(getattr(settings, "idle_timeout_minutes", 30) or 30))) + last_active = active_timer.last_heartbeat_at or active_timer.start_time + if getattr(last_active, "tzinfo", None) is not None: + last_active = last_active.replace(tzinfo=None) + stop_at = last_active + timedelta(minutes=idle_minutes) + now = local_now() + if stop_at > now: + stop_at = now + active_timer.stop_timer(end_time=stop_at) + else: # keep at now + active_timer.stop_timer() + + return jsonify({"ok": True, "stopped": True, "time_entry": active_timer.to_dict()}) + except ValueError as e: + db.session.rollback() + return error_response(str(e), error_code="review_failed", status_code=400) + except Exception as e: + db.session.rollback() + current_app.logger.warning("Timer review failed for user %s: %s", g.api_user.id, e) + return error_response("Failed to resolve review", error_code="database_error", status_code=500) + + @api_v1_time_entries_bp.route("/timer/start", methods=["POST"]) @require_api_token("write:time_entries") def start_timer(): diff --git a/app/static/idle.js b/app/static/idle.js index 463f22db8..7721bd74c 100644 --- a/app/static/idle.js +++ b/app/static/idle.js @@ -18,6 +18,7 @@ let lastHeartbeatSent = 0; let hasActiveTimer = false; let notificationPermissionRequested = false; + let pushEnsureAttempted = false; let activeIdleNotification = null; const HEARTBEAT_THROTTLE_MS = 60 * 1000; @@ -47,7 +48,11 @@ } notificationPermissionRequested = true; try { - Notification.requestPermission().catch(function(){}); + Notification.requestPermission().then(function(permission){ + if (permission === 'granted' && window.__ttEnsurePushSubscription) { + try { window.__ttEnsurePushSubscription(); } catch(e) {} + } + }).catch(function(){}); } catch(e) {} } @@ -62,12 +67,6 @@ } } - /** Credit the idle window on auto-stop: last_active + threshold (Issue #722). */ - function creditedStopTs(lastActiveTs){ - const credited = (lastActiveTs || Date.now()) + getIdleThresholdMs(); - return Math.min(credited, Date.now()); - } - function markActive(){ // While the "Still working?" grace prompt is open, only Yes/No (or the // 5-minute auto-stop) may clear it — incidental mouse/keyboard activity @@ -164,6 +163,49 @@ try { if (toastEl) toastEl.remove(); } catch(e) {} } + // --------------------------------------------------------------------------- + // Needs-review banner: idle grace expired unanswered. The timer keeps running + // on the server (idle_flagged_at); the user resolves it explicitly. + // --------------------------------------------------------------------------- + let reviewShownForTimerId = null; + let reviewBannerOpen = false; + + async function resolveIdleReview(action){ + try { + const r = await fetch('/api/timer/review', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: action }), + __ttQuiet: true, + }); + if (r.ok){ + const j = await r.json().catch(function(){ return {}; }); + const msg = j && j.stopped + ? (window.i18n?.messages?.timerStopped || 'Timer stopped') + : (window.i18n?.messages?.stillWorkingYes || 'Great — timer continues'); + if (window.toastManager && window.toastManager.success) window.toastManager.success(msg, '', 5000); + await refreshTimerUiAfterStop(); + } + } catch(e) {} + } + + function showNeedsReviewBanner(){ + if (reviewBannerOpen || promptShown) return; + reviewBannerOpen = true; + const trimLabel = window.i18n?.messages?.stillWorkingTrim || 'Trim idle time'; + const stopLabel = window.i18n?.messages?.timerStopped || 'Stop timer now'; + const contLabel = window.i18n?.messages?.stillWorkingYes || 'I am still working'; + const msg = escapeHtml( + window.i18n?.messages?.idleNeedsReview || + 'We could not reach you while you were idle. Your timer kept running — trim it back to your last activity, stop it now, or keep working.' + ); + buildReminderToast('blue', msg, [ + { label: trimLabel, style: 'primary', onClick: function(){ resolveIdleReview('trim'); } }, + { label: stopLabel, style: 'secondary', onClick: function(){ resolveIdleReview('keep'); } }, + { label: contLabel, style: 'link', onClick: function(){ resolveIdleReview('continue'); } } + ], 0, function(){ reviewBannerOpen = false; }); + } + function showNativeIdleNotification(stopTs, onStillWorking){ if (typeof Notification === 'undefined') return; if (Notification.permission !== 'granted') return; @@ -172,7 +214,7 @@ const title = window.i18n?.messages?.stillWorkingTitle || 'Still working?'; const body = window.i18n?.messages?.stillWorkingPrompt || ('You seem inactive since ' + formatTime(new Date(stopTs)) + - '. Click to confirm you are still working, or the timer will stop automatically.'); + '. Click to confirm you are still working, or the timer will be flagged for review.'); const n = new Notification(title, { body: body, tag: 'tt-still-working', @@ -201,11 +243,9 @@ const trimLabel = window.i18n?.messages?.stillWorkingTrim || 'Keep until idle'; const baseMsg = window.i18n?.messages?.stillWorkingPrompt || ('Still working? You seem inactive since ' + formatTime(new Date(stopTs)) + - '. Timer will stop automatically if you do not answer.'); + '. If you do not answer, the timer keeps running and is flagged for review.'); const deadline = Date.now() + GRACE_MS; - // Auto-stop credits the idle window (last activity + threshold), not 0 min. - const autoStopTs = creditedStopTs(stopTs); function buildMessage(){ return baseMsg + ' (' + formatCountdown(deadline - Date.now()) + ')'; @@ -224,10 +264,13 @@ }, 1000); graceTimerId = setTimeout(function(){ + // Unanswered prompt: the timer KEEPS RUNNING and is flagged for review + // (server sets idle_flagged_at). Never silently truncate recorded time. clearGraceTimers(); closeIdleNotification(); + promptShown = false; try { toastEl.remove(); } catch(e){} - stopAt(autoStopTs); + showNeedsReviewBanner(null); }, GRACE_MS); } @@ -318,8 +361,25 @@ hasActiveTimer = !!active; if (!active) return; requestNotificationPermission(); + // Make sure a Web Push subscription exists while a timer runs so the server + // can reach us with "Still working?" even when this tab is closed/hidden. + if (!pushEnsureAttempted && typeof Notification !== 'undefined' && Notification.permission === 'granted') { + pushEnsureAttempted = true; + if (typeof window.__ttEnsurePushSubscription === 'function') { + try { window.__ttEnsurePushSubscription(); } catch(e) {} + } + } // Send periodic heartbeat while tab is open (throttled to HEARTBEAT_THROTTLE_MS) if (!promptShown) sendHeartbeat(); + // Server flagged this timer for review (idle grace expired elsewhere): show banner. + if (active.needs_review){ + if (reviewShownForTimerId !== active.id){ + reviewShownForTimerId = active.id; + showNeedsReviewBanner(); + } + } else if (reviewShownForTimerId === active.id) { + reviewShownForTimerId = null; + } const threshold = getIdleThresholdMs(); const idleFor = Date.now() - lastActivity; if (idleFor >= threshold){ @@ -564,6 +624,9 @@ window.__ttShowIdlePrompt = function(stopTs){ showIdlePrompt(stopTs || (Date.now() - getIdleThresholdMs())); }; + window.__ttShowNeedsReview = function(){ + showNeedsReviewBanner(); + }; })(); diff --git a/app/static/js/sw.js b/app/static/js/sw.js index 8f9f1e4ac..d11f1a606 100644 --- a/app/static/js/sw.js +++ b/app/static/js/sw.js @@ -136,3 +136,75 @@ self.addEventListener('fetch', (event) => { return; } }); + +// --------------------------------------------------------------------------- +// Web Push (idle "Still working?" alerts + smart reminders) +// --------------------------------------------------------------------------- +self.addEventListener('push', (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch (e) { + data = { title: 'TimeTracker', message: event.data ? event.data.text() : '' }; + } + const isIdle = data.kind === 'idle_timeout' || data.kind === 'idle_needs_review'; + const title = data.title || 'TimeTracker'; + const options = { + body: data.message || '', + tag: 'tt-' + (data.kind || 'note'), + requireInteraction: isIdle, + renotify: true, + data: { url: (data.action && data.action.url) || '/', kind: data.kind || 'note' }, + }; + if (isIdle) { + options.actions = [ + { action: 'still-working', title: 'I\'m still working' }, + { action: 'stop-timer', title: 'Stop timer' }, + ]; + } + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const info = event.notification.data || {}; + const base = info.url || '/'; + + const resolveReview = (action) => + fetch('/api/timer/review', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ action }), + }).catch(() => {}); + + if (info.kind === 'idle_timeout' || info.kind === 'idle_needs_review') { + if (event.action === 'still-working') { + event.waitUntil(resolveReview('continue')); + return; + } + if (event.action === 'stop-timer') { + event.waitUntil(resolveReview('keep')); + return; + } + } + + event.waitUntil( + (async () => { + const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + for (const client of clientList) { + try { + const cUrl = new URL(client.url); + if (cUrl.origin === self.location.origin && 'focus' in client) { + await client.focus(); + if ('navigate' in client) { + try { await client.navigate(base); } catch (e) {} + } + return; + } + } catch (e) {} + } + await self.clients.openWindow(base); + })() + ); +}); diff --git a/app/static/onboarding.js b/app/static/onboarding.js index d8fb6bce9..f8d0fead8 100644 --- a/app/static/onboarding.js +++ b/app/static/onboarding.js @@ -10,6 +10,7 @@ class OnboardingManager { this.overlay = null; this.tooltip = null; this.storageKey = 'onboarding_completed'; + this.active = false; } /** @@ -19,6 +20,13 @@ class OnboardingManager { if (this.isCompleted()) { return; } + // Guard against double-init: multiple scripts auto-start the tour, and a + // second overlay would stack above the skip-confirmation dialog, making + // the tour impossible to dismiss. + if (this.active) { + return; + } + this.active = true; // Disable tour on mobile devices (width < 768px) const isMobile = window.innerWidth <= 768; @@ -71,7 +79,9 @@ class OnboardingManager { position: fixed; border: 4px solid #3b82f6; border-radius: 8px; - z-index: 10000 !important; + /* No !important: skip() must be able to lower these layers + below the skip-confirmation dialog via inline styles. */ + z-index: 10000; transition: all 0.3s ease-out; pointer-events: none; background: transparent; @@ -120,7 +130,7 @@ class OnboardingManager { padding: 24px; max-width: 400px; min-width: 300px; - z-index: 10001 !important; + z-index: 10001; animation: slideInUp 0.3s ease-out; display: block; visibility: visible; @@ -535,6 +545,8 @@ class OnboardingManager { * Position tooltip relative to target */ positionTooltip(element, step) { + // Don't re-raise the tooltip while the skip confirmation is open + if (this._skipPending) return; // Get element position first - getBoundingClientRect already accounts for scroll const rect = element.getBoundingClientRect(); @@ -711,14 +723,27 @@ class OnboardingManager { * Skip the tour */ async skip() { - // Temporarily lower the mask and overlay z-index so the confirmation dialog (z-index 2000) appears above them + // Prevent pending step-transition timers from re-raising the tooltip + // while the confirmation dialog is open. + this._skipPending = true; + try { + await this._showSkipConfirm(); + } finally { + this._skipPending = false; + } + } + + async _showSkipConfirm() { + // Temporarily lower the tour layers (overlay z-index 9998, mask 9999, + // highlight 10000, tooltip 10001) so the confirmation dialog (z-index + // 2000) appears above all of them and stays readable. const mask = document.querySelector('.onboarding-mask'); const highlight = document.querySelector('.onboarding-highlight'); const originalMaskZ = mask?.style.zIndex; const originalOverlayZ = this.overlay?.style.zIndex; const originalHighlightZ = highlight?.style.zIndex; - - // Lower mask and overlay z-index so confirmation dialog (z-index 2000) appears above + const originalTooltipZ = this.tooltip?.style.zIndex; + if (mask) { mask.style.zIndex = '1500'; } @@ -728,6 +753,9 @@ class OnboardingManager { if (highlight) { highlight.style.zIndex = '1501'; } + if (this.tooltip) { + this.tooltip.style.zIndex = '1502'; + } const confirmed = await showConfirm( 'Are you sure you want to skip the tour? You can restart it later from the Help menu.', @@ -761,6 +789,13 @@ class OnboardingManager { highlight.style.zIndex = ''; } } + if (this.tooltip) { + if (originalTooltipZ) { + this.tooltip.style.zIndex = originalTooltipZ; + } else { + this.tooltip.style.zIndex = ''; + } + } if (confirmed) { this.complete(); @@ -776,6 +811,7 @@ class OnboardingManager { if (this.tooltip) this.tooltip.remove(); document.querySelector('.onboarding-highlight')?.remove(); document.querySelector('.onboarding-mask')?.remove(); + this.active = false; // Remove resize listener if it exists if (this.resizeHandler) { diff --git a/app/static/pwa-enhancements.js b/app/static/pwa-enhancements.js index 0b863fa7c..28006e23d 100644 --- a/app/static/pwa-enhancements.js +++ b/app/static/pwa-enhancements.js @@ -240,6 +240,39 @@ class PWAEnhancements { return Notification.permission; } + /** + * Ensure a Web Push subscription exists (permission must already be granted). + * Called when a timer starts so idle "Still working?" alerts can arrive + * even with the TimeTracker tab closed. Exposed as + * window.__ttEnsurePushSubscription (used by idle.js). + */ + async ensurePushSubscription() { + try { + if (!('Notification' in window) || Notification.permission !== 'granted') return false; + if (!this.serviceWorkerRegistration || !this.serviceWorkerRegistration.pushManager) return false; + const vapidKey = this.getVapidPublicKey(); + if (!vapidKey || vapidKey.trim() === '') return false; + + const existing = await this.serviceWorkerRegistration.pushManager.getSubscription(); + if (existing) { + this.pushSubscription = existing; + await this.sendSubscriptionToServer(existing); + return true; + } + const applicationServerKey = this.urlBase64ToUint8Array(vapidKey); + const subscription = await this.serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey, + }); + this.pushSubscription = subscription; + await this.sendSubscriptionToServer(subscription); + return true; + } catch (error) { + console.warn('Push subscription failed:', error); + return false; + } + } + urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) @@ -439,8 +472,10 @@ class PWAEnhancements { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { window.pwaEnhancements = new PWAEnhancements(); + window.__ttEnsurePushSubscription = () => window.pwaEnhancements.ensurePushSubscription(); }); } else { window.pwaEnhancements = new PWAEnhancements(); + window.__ttEnsurePushSubscription = () => window.pwaEnhancements.ensurePushSubscription(); } diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index 7f8c46362..43f02ec01 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -120,6 +120,12 @@

Timers

+

{{ _('After this much inactivity, clients are asked "Still working?" and unanswered timers are flagged for review (they keep running).') }}

+
+
+ + +

{{ _('Auto-stop a flagged timer after this many hours unanswered (credited to last activity, entry stays flagged for review). 0 disables the cap.') }}

diff --git a/app/templates/base.html b/app/templates/base.html index 77003083d..2d28ad846 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -119,6 +119,7 @@ {% if current_user.is_authenticated %} + {% endif %} @@ -285,6 +286,11 @@

{{ _('AI H } } catch (e) {} }); + // Grace expired elsewhere (tab closed): timer keeps running and is + // flagged for review — show the review banner in any open tab. + socket.on('timer_needs_review', function(data){ + try { window.__ttShowNeedsReview && window.__ttShowNeedsReview(); } catch (e) {} + }); } catch (e) { console.error('Mention socket init failed', e); } diff --git a/app/utils/scheduled_tasks.py b/app/utils/scheduled_tasks.py index 93a105b68..06b9489a6 100644 --- a/app/utils/scheduled_tasks.py +++ b/app/utils/scheduled_tasks.py @@ -901,12 +901,16 @@ def check_idle_timers(): is older than ``settings.idle_timeout_minutes``: 1. First pass: set ``idle_notified_at`` and send a browser push "Still working?". - 2. Second pass (after 5-minute grace): auto-stop the timer at - ``last_active + idle_timeout`` so a forgotten timer still records at least - the configured idle window (Issue #722 — not 0 minutes). + 2. Second pass (after 5-minute grace): flag the timer ``needs_review`` + (``idle_flagged_at``) and KEEP IT RUNNING — a missed notification must never + silently truncate recorded time. Users resolve the flag via + ``POST /api/v1/timer/review`` (trim / keep / continue). + 3. Optional safety cap: if ``settings.idle_auto_stop_hours`` > 0 and the timer + stays flagged (and running) beyond that many hours, stop it credited to + ``last_active + idle_timeout`` and keep the review flag set. Clients that keep sending heartbeats keep ``last_heartbeat_at`` fresh and - clear ``idle_notified_at``, so they are never auto-stopped while active. + clear ``idle_notified_at`` / ``idle_flagged_at``, so active users are never flagged. """ from app.models import Settings from app.models.time_entry import local_now @@ -915,8 +919,10 @@ def check_idle_timers(): settings = Settings.get_settings() idle_minutes = int(getattr(settings, "idle_timeout_minutes", None) or 30) idle_minutes = max(1, min(480, idle_minutes)) + auto_stop_hours = int(getattr(settings, "idle_auto_stop_hours", 0) or 0) threshold = timedelta(minutes=idle_minutes) grace = timedelta(minutes=5) + cap = timedelta(hours=auto_stop_hours) if auto_stop_hours > 0 else None now = local_now() cutoff = now - threshold @@ -938,7 +944,8 @@ def check_idle_timers(): return 0 notified = 0 - stopped = 0 + flagged = 0 + capped_stops = 0 for entry in stale: try: if entry.idle_notified_at is None: @@ -956,40 +963,64 @@ def check_idle_timers(): if getattr(notified_at, "tzinfo", None) is not None: notified_at = notified_at.replace(tzinfo=None) if (now - notified_at) >= grace: - # Credit the configured idle window: stop at last known - # activity + threshold (not last_active alone, which - # recorded 0 min when heartbeats never arrived). - last_active = entry.last_heartbeat_at or entry.start_time - if getattr(last_active, "tzinfo", None) is not None: - last_active = last_active.replace(tzinfo=None) - stop_at = last_active + threshold if last_active else now - if stop_at > now: - stop_at = now - # stop_timer commits; avoid double-commit issues by calling it last - entry_id = entry.id - user_id = entry.user_id - entry.stop_timer(end_time=stop_at) - stopped += 1 - logger.info( - "Idle auto-stop timer %s user=%s at %s", - entry_id, - user_id, - stop_at, - ) - try: - from app import socketio - - socketio.emit( - "timer_stopped", + if entry.idle_flagged_at is None: + entry.idle_flagged_at = now + entry.updated_at = now + flagged += 1 + logger.info( + "Idle needs-review flag set for timer %s user=%s (timer keeps running)", + entry.id, + entry.user_id, + ) + _emit_timer_event( + "timer_needs_review", { - "user_id": user_id, - "timer_id": entry_id, - "duration": entry.duration_formatted, - "reason": "idle_timeout", + "user_id": entry.user_id, + "timer_id": entry.id, + "reason": "idle_needs_review", + "message": ( + "Your timer kept running while you were idle and now needs review. " + "Trim it to your last activity or keep the time." + ), }, + user_id=entry.user_id, ) - except Exception as emit_err: - logger.debug("socketio emit after idle stop failed: %s", emit_err) + elif cap is not None: + flagged_at = entry.idle_flagged_at + if getattr(flagged_at, "tzinfo", None) is not None: + flagged_at = flagged_at.replace(tzinfo=None) + if (now - flagged_at) >= cap: + # Safety cap: credit the configured idle window and stop, + # but keep the review flag so the user can adjust afterwards. + last_active = entry.last_heartbeat_at or entry.start_time + if getattr(last_active, "tzinfo", None) is not None: + last_active = last_active.replace(tzinfo=None) + stop_at = last_active + threshold if last_active else now + if stop_at > now: + stop_at = now + entry_id = entry.id + user_id = entry.user_id + entry.stop_timer(end_time=stop_at) + entry.idle_flagged_at = now + entry.updated_at = now + db.session.commit() + capped_stops += 1 + logger.info( + "Idle safety-cap stop for timer %s user=%s at %s (still needs review)", + entry_id, + user_id, + stop_at, + ) + _emit_timer_event( + "timer_stopped", + { + "user_id": user_id, + "timer_id": entry_id, + "duration": entry.duration_formatted, + "reason": "idle_auto_stop_cap", + }, + user_id=user_id, + ) except Exception as e: logger.warning( "Idle check failed for entry %s: %s", @@ -1004,9 +1035,11 @@ def check_idle_timers(): logger.warning("Idle check commit failed: %s", e) db.session.rollback() - if notified or stopped: - logger.info("Idle check: notified=%d stopped=%d", notified, stopped) - return notified + stopped + if notified or flagged or capped_stops: + logger.info( + "Idle check: notified=%d flagged=%d cap_stopped=%d", notified, flagged, capped_stops + ) + return notified + flagged + capped_stops except Exception as e: logger.error("Error in check_idle_timers: %s", e) try: @@ -1016,6 +1049,20 @@ def check_idle_timers(): return 0 +def _emit_timer_event(event, payload, user_id=None): + """Emit a Socket.IO event to the user's room (best-effort, in-tab prompt only).""" + try: + from app import socketio + + room = f"user_{user_id}" if user_id is not None else None + if room: + socketio.emit(event, payload, room=room) + else: + socketio.emit(event, payload) + except Exception as e: + logger.debug("socketio emit %s failed: %s", event, e) + + def _send_idle_push(entry): """Notify the user that their timer is idle ("Still working?"). @@ -1038,7 +1085,7 @@ def _send_idle_push(entry): note = { "kind": "idle_timeout", "title": "Still working?", - "message": "Your timer has been idle. Confirm you are still working or it will stop automatically.", + "message": "Your timer has been idle. Confirm you are still working or it will be flagged for review.", "type": "warning", "action": {"url": "/", "label": "Open TimeTracker"}, "timer_id": entry.id, diff --git a/browser-extension/background.js b/browser-extension/background.js index 8b4701951..fa630fb10 100644 --- a/browser-extension/background.js +++ b/browser-extension/background.js @@ -12,6 +12,7 @@ import { const ALARM_NAME = 'tt-timer-poll'; const IDLE_STOP_ALARM = 'tt-idle-stop'; const IDLE_NOTIFICATION_ID = 'tt-still-working'; +const NEEDS_REVIEW_NOTIFICATION_ID = 'tt-needs-review'; const POLL_MINUTES = 0.25; // ~15s const GRACE_MINUTES = 5; const DEFAULT_IDLE_TIMEOUT_MINUTES = 30; @@ -115,7 +116,7 @@ async function beginIdleGrace(stopAtMs) { type: 'basic', iconUrl: 'icons/running-128.png', title: 'Still working?', - message: `Your timer will stop in ${GRACE_MINUTES} minutes if you do not answer.`, + message: `Answer within ${GRACE_MINUTES} minutes or the timer will be flagged for review (it keeps running).`, priority: 2, requireInteraction: true, buttons: [ @@ -150,6 +151,27 @@ async function confirmStillWorking() { await sendServerHeartbeat(); } +/** Idle grace expired unanswered: the timer KEEPS RUNNING server-side and is + * flagged for review. Never silently stop recorded time — just tell the user. */ +async function notifyNeedsReview(timer) { + const { needs_review_notified_for } = await chrome.storage.local.get('needs_review_notified_for'); + if (needs_review_notified_for && needs_review_notified_for === timer?.id) return; + await chrome.storage.local.set({ needs_review_notified_for: timer?.id }); + try { + await chrome.notifications.create(NEEDS_REVIEW_NOTIFICATION_ID, { + type: 'basic', + iconUrl: 'icons/running-128.png', + title: 'Timer needs review', + message: + 'You were idle and did not answer. Your timer kept running — open TimeTracker to trim the idle time or stop it.', + priority: 2, + requireInteraction: true, + }); + } catch (error) { + console.debug('[TimeTracker] needs-review notification failed:', error); + } +} + async function stopTimerForIdle({ stopAtMs = null } = {}) { const { server_url, api_token, logged_out, idle_grace_stop_at } = await chrome.storage.local.get([ 'server_url', @@ -196,11 +218,17 @@ async function refreshTimerStatus({ force = false } = {}) { const idleNotified = Boolean( status?.idle_notified || status?.timer?.idle_notified ); - if (idleNotified) { + const needsReview = Boolean( + status?.needs_review || status?.timer?.needs_review + ); + if (idleNotified && !needsReview) { // Credit the idle window (Issue #722): stop at last_active + threshold ≈ now // when the server just notified, not at last_active alone (0 min). await beginIdleGrace(Date.now()); } + if (needsReview) { + await notifyNeedsReview(status.timer); + } // Only refresh server heartbeat while the OS reports the user as active. // Heartbeating during idle/locked would defeat the server-side safety net. const { idle_grace_active } = await chrome.storage.local.get('idle_grace_active'); @@ -269,13 +297,19 @@ chrome.runtime.onStartup.addListener(() => { refreshTimerStatus({ force: true }); }); -chrome.alarms.onAlarm.addListener((alarm) => { +chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name === ALARM_NAME) { refreshTimerStatus(); return; } if (alarm.name === IDLE_STOP_ALARM) { - stopTimerForIdle(); + // Grace expired unanswered: keep the timer running, flag for review. + await clearIdleGraceState(); + const { last_timer_status } = await chrome.storage.local.get('last_timer_status'); + if (last_timer_status?.active && last_timer_status?.timer) { + await notifyNeedsReview(last_timer_status.timer); + } + return; } }); @@ -319,6 +353,7 @@ chrome.idle.onStateChanged.addListener(async (newState) => { // User returned before grace expired — cancel pending auto-stop and // tell the server so the server-side grace window also resets. await clearIdleGraceState(); + await chrome.storage.local.remove('needs_review_notified_for'); await sendServerHeartbeat(); return; } @@ -349,6 +384,17 @@ chrome.notifications.onButtonClicked.addListener(async (notificationId, buttonIn }); chrome.notifications.onClicked.addListener(async (notificationId) => { + if (notificationId === NEEDS_REVIEW_NOTIFICATION_ID) { + const { server_url } = await getCredentials(); + if (server_url) { + try { + await chrome.tabs.create({ url: server_url }); + } catch (_) { + /* ignore */ + } + } + return; + } if (notificationId !== IDLE_NOTIFICATION_ID) return; // Clicking the notification body counts as "still working". await confirmStillWorking(); diff --git a/desktop/src/main/idle.js b/desktop/src/main/idle.js index 086977cfd..75471b1d7 100644 --- a/desktop/src/main/idle.js +++ b/desktop/src/main/idle.js @@ -3,8 +3,10 @@ * * Polls powerMonitor.getSystemIdleTime() every 60s. When an active timer is * running and the OS idle time exceeds idle_timeout_minutes, shows a - * "Still working?" notification, notifies the renderer, and after a 5-minute - * grace window auto-stops the timer via the API (backdated to last activity). + * "Still working?" notification and notifies the renderer. If the 5-minute + * grace window expires unanswered, the timer KEEPS RUNNING and is flagged + * for review (server sets idle_flagged_at) — recorded time is never + * silently truncated. */ const { powerMonitor, Notification, net } = require('electron'); @@ -26,6 +28,7 @@ function createIdleMonitor({ store, sendToMainWindow, focusMainWindow }) { let timerActive = false; let idleTimeoutMinutes = DEFAULT_IDLE_TIMEOUT_MINUTES; let stopAtMs = null; + let needsReviewNotifiedFor = null; function clearGrace() { if (graceTimer) { @@ -90,7 +93,7 @@ function createIdleMonitor({ store, sendToMainWindow, focusMainWindow }) { if (!Notification.isSupported()) return; const notification = new Notification({ title: 'Still working?', - body: `Your timer will stop in 5 minutes if you do not answer.`, + body: 'Answer within 5 minutes or the timer will be flagged for review (it keeps running).', urgency: 'critical', }); notification.on('click', () => { @@ -103,6 +106,33 @@ function createIdleMonitor({ store, sendToMainWindow, focusMainWindow }) { } } + /** Grace expired unanswered: keep the timer running, flag for review. */ + function flagNeedsReview() { + clearGrace(); + if (!timerActive) return; + showNeedsReviewNotification(); + sendToMainWindow('idle:needs-review', { + idleTimeoutMinutes, + }); + } + + function showNeedsReviewNotification() { + try { + if (!Notification.isSupported()) return; + const notification = new Notification({ + title: 'Timer needs review', + body: 'You were idle and did not answer. Your timer kept running — trim the idle time or stop it.', + urgency: 'critical', + }); + notification.on('click', () => { + focusMainWindow(); + }); + notification.show(); + } catch (e) { + console.debug('[IdleMonitor] notification failed:', e.message); + } + } + function beginGrace() { if (promptShown || !timerActive) return; promptShown = true; @@ -115,18 +145,10 @@ function createIdleMonitor({ store, sendToMainWindow, focusMainWindow }) { }); focusMainWindow(); graceTimer = setTimeout(() => { - autoStop(); + flagNeedsReview(); }, GRACE_MS); } - async function autoStop() { - const at = stopAtMs; - clearGrace(); - if (!timerActive) return; - timerActive = false; - await stopTimerAt(at); - } - async function confirmStillWorking() { clearGrace(); await sendHeartbeat(); @@ -148,13 +170,25 @@ function createIdleMonitor({ store, sendToMainWindow, focusMainWindow }) { } if (!active) { clearGrace(); + needsReviewNotifiedFor = null; return; } // Server already marked this timer idle (#722) — show prompt even if OS idle // time has not crossed the threshold yet (e.g. after reconnect). - if (data && data.idle_notified && !promptShown) { + const timer = (data && data.timer) || {}; + const idleNotified = Boolean(data.idle_notified || timer.idle_notified); + const needsReview = Boolean(data.needs_review || timer.needs_review); + if (idleNotified && !promptShown && !needsReview) { beginGrace(); } + // Server flagged the timer for review (grace expired elsewhere) — surface it. + if (needsReview && needsReviewNotifiedFor !== timer.id) { + needsReviewNotifiedFor = timer.id || 'unknown'; + showNeedsReviewNotification(); + sendToMainWindow('idle:needs-review', { idleTimeoutMinutes }); + } else if (!needsReview) { + needsReviewNotifiedFor = null; + } } function tick() { diff --git a/desktop/src/main/preload.js b/desktop/src/main/preload.js index 996cbf196..09c4ee92c 100644 --- a/desktop/src/main/preload.js +++ b/desktop/src/main/preload.js @@ -67,6 +67,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('idle:timer-stopped', handler); return () => ipcRenderer.removeListener('idle:timer-stopped', handler); }, + onIdleNeedsReview: (callback) => { + const handler = (_event, data) => callback(data); + ipcRenderer.on('idle:needs-review', handler); + return () => ipcRenderer.removeListener('idle:needs-review', handler); + }, idleStillWorking: () => ipcRenderer.send('idle:still-working'), idleStop: () => ipcRenderer.send('idle:stop'), diff --git a/desktop/src/renderer-react/src/main.jsx b/desktop/src/renderer-react/src/main.jsx index 36c04502a..115c9568c 100644 --- a/desktop/src/renderer-react/src/main.jsx +++ b/desktop/src/renderer-react/src/main.jsx @@ -301,7 +301,7 @@ function App() { const unsubPrompt = window.electronAPI.onIdlePrompt((payload) => { const ok = window.confirm( - 'Still working? Your timer will stop automatically if you do not confirm.\n\nPress OK if you are still working, or Cancel to stop the timer now.', + 'Still working? If you do not confirm, the timer keeps running and will be flagged for review.\n\nPress OK if you are still working, or Cancel to stop the timer now.', ); if (ok) { window.electronAPI.idleStillWorking(); @@ -317,11 +317,16 @@ function App() { const unsubDismissed = window.electronAPI.onIdleDismissed?.(() => { // no-op; heartbeat already sent from main }); + const unsubNeedsReview = window.electronAPI.onIdleNeedsReview?.(() => { + refreshCoreData(); + showToast('Timer flagged for review — it kept running while you were idle', 'warning'); + }); return () => { if (typeof unsubPrompt === 'function') unsubPrompt(); if (typeof unsubStopped === 'function') unsubStopped(); if (typeof unsubDismissed === 'function') unsubDismissed(); + if (typeof unsubNeedsReview === 'function') unsubNeedsReview(); }; }, [apiClient, refreshCoreData, showToast]); diff --git a/migrations/versions/183_add_idle_needs_review.py b/migrations/versions/183_add_idle_needs_review.py new file mode 100644 index 000000000..b65af7b7a --- /dev/null +++ b/migrations/versions/183_add_idle_needs_review.py @@ -0,0 +1,48 @@ +"""Add idle_flagged_at to time_entries for needs-review idle handling. + +When the idle grace window expires unanswered, the timer is no longer +auto-stopped: it keeps running and is flagged for review instead. + +Revision ID: 183_add_idle_needs_review +Revises: 182_add_project_last_used_at +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "183_add_idle_needs_review" +down_revision = "182_add_project_last_used_at" +branch_labels = None +depends_on = None + + +def _has_column(inspector, table_name: str, column_name: str) -> bool: + try: + return column_name in {c["name"] for c in inspector.get_columns(table_name)} + except Exception: + return False + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if not _has_column(inspector, "time_entries", "idle_flagged_at"): + op.add_column( + "time_entries", + sa.Column("idle_flagged_at", sa.DateTime(), nullable=True), + ) + if not _has_column(inspector, "settings", "idle_auto_stop_hours"): + op.add_column( + "settings", + sa.Column("idle_auto_stop_hours", sa.Integer(), nullable=False, server_default="0"), + ) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if _has_column(inspector, "settings", "idle_auto_stop_hours"): + op.drop_column("settings", "idle_auto_stop_hours") + if _has_column(inspector, "time_entries", "idle_flagged_at"): + op.drop_column("time_entries", "idle_flagged_at") diff --git a/tests/test_api_v1.py b/tests/test_api_v1.py index b05462680..478aff30c 100644 --- a/tests/test_api_v1.py +++ b/tests/test_api_v1.py @@ -547,7 +547,8 @@ def test_timer_heartbeat_no_active(self, client, api_token): assert response.status_code == 400 def test_check_idle_timers_notifies_then_stops(self, client, api_token, test_user, test_project, app): - """Server idle job notifies on first pass and auto-stops after grace.""" + """Server idle job notifies on first pass, then flags for review after + grace — the timer keeps running so recorded time is never truncated.""" from datetime import timedelta from app.models import Settings @@ -578,23 +579,124 @@ def test_check_idle_timers_notifies_then_stops(self, client, api_token, test_use assert refreshed.end_time is None assert refreshed.idle_notified_at is not None - # Second pass after grace: auto-stop at last_heartbeat + idle_timeout - last_hb = refreshed.last_heartbeat_at + # Second pass after grace: flag for review, timer KEEPS RUNNING refreshed.idle_notified_at = local_now() - timedelta(minutes=6) db.session.commit() + check_idle_timers() + flagged = db.session.get(TimeEntry, timer_id) + assert flagged.end_time is None + assert flagged.idle_flagged_at is not None + + def test_check_idle_timers_safety_cap_stops_and_keeps_flag(self, client, api_token, test_user, test_project, app): + """With the safety cap enabled, an unanswered flagged timer is stopped + credited to last activity but stays flagged for review.""" + from datetime import timedelta + + from app.models import Settings + from app.models.time_entry import local_now + from app.utils.scheduled_tasks import check_idle_timers + + settings = Settings.get_settings() + settings.idle_timeout_minutes = 30 + settings.idle_auto_stop_hours = 1 + db.session.commit() + + start = local_now() - timedelta(hours=4) + timer = TimeEntry( + user_id=int(test_user), + project_id=test_project.id, + start_time=start, + end_time=None, + source="api", + billable=True, + ) + timer.last_heartbeat_at = local_now() - timedelta(hours=3) + timer.idle_notified_at = local_now() - timedelta(minutes=30) + timer.idle_flagged_at = local_now() - timedelta(hours=2) + db.session.add(timer) + db.session.commit() + timer_id = timer.id + check_idle_timers() stopped = db.session.get(TimeEntry, timer_id) assert stopped.end_time is not None - assert stopped.idle_notified_at is None - expected_stop = last_hb + timedelta(minutes=30) + # Still flagged so the user reviews/adjusts the entry + assert stopped.idle_flagged_at is not None + + expected_stop = timer.last_heartbeat_at + timedelta(minutes=30) if getattr(expected_stop, "tzinfo", None) is not None: expected_stop = expected_stop.replace(tzinfo=None) end = stopped.end_time if getattr(end, "tzinfo", None) is not None: end = end.replace(tzinfo=None) assert abs((end - expected_stop).total_seconds()) < 5 - # At least ~90 min recorded (start was 2h ago, stop at hb+30m) - assert (stopped.duration_seconds or 0) >= 85 * 60 + + def test_timer_review_endpoints(self, client, api_token, test_user, test_project, app): + """POST /api/v1/timer/review supports trim / keep / continue actions.""" + import json as _json + from datetime import timedelta + + from app.models import Settings + from app.models.time_entry import local_now + + settings = Settings.get_settings() + settings.idle_timeout_minutes = 30 + db.session.commit() + + def make_timer(): + # End any still-running timer so active_timer resolves to the new one + active = TimeEntry.query.filter_by(user_id=int(test_user), end_time=None).all() + for t in active: + t.stop_timer() + timer = TimeEntry( + user_id=int(test_user), + project_id=test_project.id, + start_time=local_now() - timedelta(hours=2), + end_time=None, + source="api", + billable=True, + ) + timer.last_heartbeat_at = local_now() - timedelta(hours=1) + timer.idle_flagged_at = local_now() - timedelta(minutes=10) + db.session.add(timer) + db.session.commit() + return timer + + headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"} + + # continue: clears the flag, keeps running + timer = make_timer() + response = client.post( + "/api/v1/timer/review", headers=headers, data=_json.dumps({"action": "continue"}) + ) + assert response.status_code == 200 + data = response.get_json() + assert data["stopped"] is False + refreshed = db.session.get(TimeEntry, timer.id) + assert refreshed.end_time is None + assert refreshed.idle_flagged_at is None + + # trim: stops credited to last activity + idle timeout + timer = make_timer() + response = client.post( + "/api/v1/timer/review", headers=headers, data=_json.dumps({"action": "trim"}) + ) + assert response.status_code == 200 + data = response.get_json() + assert data["stopped"] is True + trimmed = db.session.get(TimeEntry, timer.id) + assert trimmed.end_time is not None + assert (trimmed.duration_seconds or 0) >= 85 * 60 + + # keep: stops at now + timer = make_timer() + response = client.post( + "/api/v1/timer/review", headers=headers, data=_json.dumps({"action": "keep"}) + ) + assert response.status_code == 200 + kept = db.session.get(TimeEntry, timer.id) + assert kept.end_time is not None + assert (kept.duration_seconds or 0) >= 115 * 60 class TestTasks: From 7ab4f119397304d1c8a7bda148476776c59da5aa Mon Sep 17 00:00:00 2001 From: Giorgio Date: Thu, 3 Sep 2026 15:47:29 +0200 Subject: [PATCH 3/3] docs(changelog): unreleased entries for 746 offline fixes, onboarding tour fix, idle needs-review flow --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e23e230..badd71606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Idle needs-review flow** — When a running timer's idle grace expires while no TimeTracker window can answer (tab closed, machine asleep), the time entry is now flagged **needs review** instead of silently lost. A review banner in the web app, a push notification via the service worker, and scheduled-task detection surface these entries so they can be confirmed or corrected; the behavior is configurable in Admin → Settings. The desktop app and browser extension report the same situation, and the v1 API exposes flag/resolve endpoints. Includes migration `183_add_idle_needs_review`. + ### Fixed +- **"Service temporarily unavailable" after leaving the dashboard open (#746)** — When the backend went down while a page stayed open, the service worker answered `/api/*` requests with a plain-text `Offline` body, so `.json()` parsing threw `SyntaxError` on top of the connection failure; the chat widget re-polled every 30 seconds forever, and identical error toasts stacked up and evicted the ones carrying the Retry/Refresh buttons. Synthetic offline responses are now JSON, chat-widget polling backs off (2 minutes) after a failure and pauses in hidden tabs, repeated error toasts are deduplicated so the Retry/Refresh actions stay reachable, and the offline indicator bar is removed as soon as connectivity is restored instead of lingering until a reload. +- **Onboarding tour could not be dismissed** — The tour could auto-start twice, stacking a second overlay above its own skip-confirmation dialog, and `!important` z-indexes plus step-transition timers re-raised the tooltip over the "Are you sure you want to skip the tour?" prompt. A double-init guard, non-forced layering, and a pending-skip guard keep the confirmation readable and the tour dismissible. + - **Datetime fields ignore the chosen time format** — Native `datetime-local` inputs (the "Forgot to end your workday?" workday modals, workday history corrections, contact/deal/lead activity forms) render in the browser locale — e.g. 12h AM/PM on en-US — no matter what time format the user or system settings chose. They are now initialized as Flatpickr datetime pickers that display in the user's preferred date + time format (24h by default) while still submitting the same wire format; min/max bounds move to the picker. A regression test enforces that every `datetime-local` input stays prefs-aware. The recurring-tasks "last run" cell and the admin version-update published timestamp also now honor the preference instead of the browser locale. ## [5.13.2] - 2026-08-27