diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e23e23..badd7160 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 diff --git a/app/config.py b/app/config.py index 05a0b4d0..b59457cd 100644 --- a/app/config.py +++ b/app/config.py @@ -52,6 +52,12 @@ class Config: SINGLE_ACTIVE_TIMER = os.getenv("SINGLE_ACTIVE_TIMER", "true").lower() == "true" IDLE_TIMEOUT_MINUTES = int(os.getenv("IDLE_TIMEOUT_MINUTES", 30)) + # Web Push (VAPID) — required for browser push notifications ("Still working?" + # idle alerts with the tab closed, smart reminders). Generate e.g. with py_vapid. + VAPID_PUBLIC_KEY = os.getenv("VAPID_PUBLIC_KEY", "") + VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY", "") + VAPID_CONTACT_EMAIL = os.getenv("VAPID_CONTACT_EMAIL", "") + # User management (default false for production-safe deployments) ALLOW_SELF_REGISTER = os.getenv("ALLOW_SELF_REGISTER", "false").lower() == "true" ADMIN_USERNAMES = [u.strip() for u in os.getenv("ADMIN_USERNAMES", "admin").split(",") if u.strip()] diff --git a/app/models/settings.py b/app/models/settings.py index 9ea0e4fa..708b8276 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -73,6 +73,9 @@ class Settings(db.Model): single_active_timer = db.Column(db.Boolean, default=True, nullable=False) allow_self_register = db.Column(db.Boolean, default=True, nullable=False) idle_timeout_minutes = db.Column(db.Integer, default=30, nullable=False) + # Safety cap: auto-stop a running timer flagged for review after N hours + # unanswered (credited back to last activity). 0 disables the cap. + idle_auto_stop_hours = db.Column(db.Integer, default=0, nullable=False) backup_retention_days = db.Column(db.Integer, default=30, nullable=False) backup_time = db.Column(db.String(5), default="02:00", nullable=False) # HH:MM format export_delimiter = db.Column(db.String(1), default=",", nullable=False) @@ -275,6 +278,7 @@ def __init__(self, **kwargs): self.single_active_timer = kwargs.get("single_active_timer", Config.SINGLE_ACTIVE_TIMER) self.allow_self_register = kwargs.get("allow_self_register", Config.ALLOW_SELF_REGISTER) self.idle_timeout_minutes = kwargs.get("idle_timeout_minutes", Config.IDLE_TIMEOUT_MINUTES) + self.idle_auto_stop_hours = kwargs.get("idle_auto_stop_hours", 0) self.backup_retention_days = kwargs.get("backup_retention_days", Config.BACKUP_RETENTION_DAYS) self.backup_time = kwargs.get("backup_time", Config.BACKUP_TIME) self.export_delimiter = kwargs.get("export_delimiter", ",") @@ -587,6 +591,7 @@ def to_dict(self): "single_active_timer": self.single_active_timer, "allow_self_register": self.allow_self_register, "idle_timeout_minutes": self.idle_timeout_minutes, + "idle_auto_stop_hours": getattr(self, "idle_auto_stop_hours", 0), "backup_retention_days": self.backup_retention_days, "backup_time": self.backup_time, "export_delimiter": self.export_delimiter, @@ -924,6 +929,7 @@ def _initialize_from_env(cls, settings_instance): "SINGLE_ACTIVE_TIMER": "single_active_timer", "ALLOW_SELF_REGISTER": "allow_self_register", "IDLE_TIMEOUT_MINUTES": "idle_timeout_minutes", + "IDLE_AUTO_STOP_HOURS": "idle_auto_stop_hours", "BACKUP_RETENTION_DAYS": "backup_retention_days", "BACKUP_TIME": "backup_time", "DEFAULT_DAILY_WORKING_HOURS": "default_daily_working_hours", diff --git a/app/models/time_entry.py b/app/models/time_entry.py index 92098148..fb8fcf94 100644 --- a/app/models/time_entry.py +++ b/app/models/time_entry.py @@ -37,6 +37,9 @@ class TimeEntry(db.Model): # Idle timeout: clients POST /timer/heartbeat while active; server job auto-stops when stale last_heartbeat_at = db.Column(db.DateTime, nullable=True, index=True) idle_notified_at = db.Column(db.DateTime, nullable=True) + # Set when the idle grace window expired unanswered: the timer keeps running + # but is flagged so the user can trim/adjust it later instead of losing time. + idle_flagged_at = db.Column(db.DateTime, nullable=True) created_at = db.Column(db.DateTime, default=local_now, nullable=False) updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) @@ -284,6 +287,13 @@ def record_heartbeat(self, at=None): now = at if at is not None else local_now() self.last_heartbeat_at = now self.idle_notified_at = None + self.idle_flagged_at = None + self.updated_at = local_now() + + def clear_idle_flags(self): + """Clear pending idle notification and needs-review flag (user confirmed activity or resolved review).""" + self.idle_notified_at = None + self.idle_flagged_at = None self.updated_at = local_now() def stop_timer(self, end_time=None): @@ -298,6 +308,7 @@ def stop_timer(self, end_time=None): self.end_time = local_now() self.idle_notified_at = None + self.idle_flagged_at = None self.calculate_duration() self.updated_at = local_now() @@ -383,6 +394,8 @@ def to_dict(self): "last_heartbeat_at": self.last_heartbeat_at.isoformat() if self.last_heartbeat_at else None, "idle_notified": bool(self.idle_notified_at), "idle_notified_at": self.idle_notified_at.isoformat() if self.idle_notified_at else None, + "needs_review": bool(self.idle_flagged_at), + "idle_flagged_at": self.idle_flagged_at.isoformat() if self.idle_flagged_at else None, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, "user": self.user.username if self.user else None, diff --git a/app/routes/admin.py b/app/routes/admin.py index 7d5ac0fa..c05b8e04 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1415,6 +1415,12 @@ def settings(): settings_obj.single_active_timer = request.form.get("single_active_timer") == "on" settings_obj.allow_self_register = request.form.get("allow_self_register") == "on" settings_obj.idle_timeout_minutes = int(request.form.get("idle_timeout_minutes", 30)) + try: + settings_obj.idle_auto_stop_hours = max( + 0, min(168, int(request.form.get("idle_auto_stop_hours", 0) or 0)) + ) + except (TypeError, ValueError): + settings_obj.idle_auto_stop_hours = getattr(settings_obj, "idle_auto_stop_hours", 0) or 0 settings_obj.backup_retention_days = int(request.form.get("backup_retention_days", 30)) settings_obj.backup_time = request.form.get("backup_time", "02:00") settings_obj.export_delimiter = request.form.get("export_delimiter", ",") diff --git a/app/routes/api.py b/app/routes/api.py index 824c5eeb..21f21bc7 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -334,6 +334,7 @@ def timer_status(): "current_duration": active_timer.current_duration_seconds, "duration_formatted": active_timer.duration_formatted, "idle_notified": bool(active_timer.idle_notified_at), + "needs_review": bool(active_timer.idle_flagged_at), "last_heartbeat_at": ( active_timer.last_heartbeat_at.isoformat() if active_timer.last_heartbeat_at else None ), @@ -396,6 +397,71 @@ def api_timer_heartbeat(): return ("", 204) +@api_bp.route("/api/timer/review", methods=["POST"]) +@login_required +@deprecated_session_api("/api/v1/timer/review") +def api_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 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 jsonify({"error": "action must be trim, keep or continue"}), 400 + + active_timer = current_user.active_timer + if not active_timer: + return jsonify({"error": "No active timer"}), 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": current_user.id, "entry_id": active_timer.id}): + db.session.rollback() + return jsonify({"error": "Failed to resolve review"}), 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 jsonify({"error": "invalid end_time"}), 400 + if end_time <= active_timer.start_time: + return jsonify({"error": "end_time must be after start_time"}), 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 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 7f5cb697..1b552d21 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/error-handling-enhanced.js b/app/static/error-handling-enhanced.js index 4efddfcb..1c061bba 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/idle.js b/app/static/idle.js index 463f22db..7721bd74 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 a55d40eb..d11f1a60 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(); } } @@ -126,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 d8fb6bce..f8d0fead 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 0b863fa7..28006e23 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 7f8c4636..43f02ec0 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -120,6 +120,12 @@
{{ _('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.') }}