From 5ced049952fac6f15a74928ba9e38330fef4c094 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 2 Sep 2026 19:27:57 +0200 Subject: [PATCH 1/5] feat(tasks): show tracked time on list and detail views (#745) Replace the redundant Actions column with Tracked hours, surface totals on the task detail page, and include Tracked Hours in CSV export via a bulk aggregate. --- app/routes/tasks.py | 16 ++++++++++++++++ app/templates/tasks/_tasks_list.html | 12 ++++++++---- app/templates/tasks/view.html | 16 +++++++++++++++- tests/test_bulk_task_operations.py | 1 + tests/test_routes.py | 7 +++++++ 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 772b7d330..fcf1c0bc9 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -1468,6 +1468,20 @@ def parse_ids(param_name): tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all() + # Bulk-aggregate tracked hours to avoid N+1 queries when reading task.total_hours + if tasks: + task_ids = [task.id for task in tasks] + results = ( + db.session.query(TimeEntry.task_id, db.func.sum(TimeEntry.duration_seconds).label("total_seconds")) + .filter(TimeEntry.task_id.in_(task_ids), TimeEntry.end_time.isnot(None)) + .group_by(TimeEntry.task_id) + .all() + ) + total_hours_map = {task_id: total_seconds for task_id, total_seconds in results} + for task in tasks: + total_seconds = total_hours_map.get(task.id, 0) or 0 + task._cached_total_hours = round(total_seconds / 3600, 2) if total_seconds else 0.0 + # Create CSV in memory output = io.StringIO() writer = csv.writer(output) @@ -1486,6 +1500,7 @@ def parse_ids(param_name): "Created By", "Due Date", "Estimated Hours", + "Tracked Hours", "Created At", "Updated At", ] @@ -1506,6 +1521,7 @@ def parse_ids(param_name): task.creator.display_name if task.creator else "", task.due_date.strftime("%Y-%m-%d") if task.due_date else "", task.estimated_hours or "", + task.total_hours if task.total_hours else "", ( convert_app_datetime_to_user(task.created_at, user=current_user).strftime("%Y-%m-%d %H:%M:%S") if task.created_at diff --git a/app/templates/tasks/_tasks_list.html b/app/templates/tasks/_tasks_list.html index 4afe6dd24..7c4637337 100644 --- a/app/templates/tasks/_tasks_list.html +++ b/app/templates/tasks/_tasks_list.html @@ -34,8 +34,8 @@

Status Due {{ _('Estimated') }} + {{ _('Tracked') }} Progress - Actions @@ -90,15 +90,19 @@

— {% endif %} + + {% if task.total_hours %} + {{ task.total_hours }} {{ _('h') }} + {% else %} + — + {% endif %} + {% set pct = task.progress_percentage or 0 %}
- - View - {% else %} {% endfor %} diff --git a/app/templates/tasks/view.html b/app/templates/tasks/view.html index 89b1a7055..367db8f6c 100644 --- a/app/templates/tasks/view.html +++ b/app/templates/tasks/view.html @@ -150,7 +150,12 @@

-

Time Entries

+
+

{{ _('Time Entries') }}

+ + {{ _('Total') }}: {{ task.total_hours }} {{ _('h') }} + +
@@ -196,6 +201,11 @@

Time Entries

+ {% if time_entries|length == 100 %} +

+ {{ _('Showing last %(count)s entries. Total above includes all time logged on this task.', count=100) }} +

+ {% endif %} @@ -253,6 +263,10 @@

{{ _('Estimated') }}

{% if task.estimated_hours %}{{ task.estimated_hours }} {{ _('h') }}{% else %}—{% endif %}

+
+

{{ _('Tracked') }}

+

{{ task.total_hours }} {{ _('h') }}{% if task.total_billable_hours %} ({{ task.total_billable_hours }} {{ _('h') }} {{ _('billable') }}){% endif %}

+
diff --git a/tests/test_bulk_task_operations.py b/tests/test_bulk_task_operations.py index c3345c642..369f56820 100644 --- a/tests/test_bulk_task_operations.py +++ b/tests/test_bulk_task_operations.py @@ -547,6 +547,7 @@ def test_export_tasks_csv(authenticated_client, app, tasks_for_bulk): assert "Name" in csv_data assert "Project" in csv_data assert "Status" in csv_data + assert "Tracked Hours" in csv_data # Check that task data is in CSV assert tasks_for_bulk[0].name in csv_data diff --git a/tests/test_routes.py b/tests/test_routes.py index 26c39059b..673f98b4f 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -595,6 +595,10 @@ def test_tasks_list_page(authenticated_client): """Test tasks list page.""" response = authenticated_client.get("/tasks") assert response.status_code == 200 + html = response.get_data(as_text=True) + assert "Tracked" in html + # Actions column removed in favor of Tracked; task name remains the primary link + assert ">Actions" not in html @pytest.mark.integration @@ -613,6 +617,9 @@ def test_task_detail_page(authenticated_client, task, app): with app.app_context(): response = authenticated_client.get(f"/tasks/{task.id}") assert response.status_code == 200 + html = response.get_data(as_text=True) + assert "Tracked" in html + assert "Total" in html @pytest.mark.integration From dee99dc96be8d25a7fcef66ae309daeb120d2f2e Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 2 Sep 2026 19:28:07 +0200 Subject: [PATCH 2/5] fix(pwa): stop SW fake 503s after tab idle (#746) Stale keep-alive sockets on resume were turned into synthetic Offline 503s, which broke JSON clients and stuck error toasts. Bypass /api in the service worker, quiet remaining background polls, and make retry controls visible. --- CHANGELOG.md | 1 + app/static/error-handling-enhanced.js | 46 +++++++++++++----- app/static/js/sw.js | 29 +++--------- .../components/activity_feed_widget.html | 6 ++- .../components/persistent_chat_widget.html | 23 +++++++-- app/templates/tasks/_kanban.html | 6 ++- tests/test_reconnect_on_resume.py | 47 +++++++++++++++++-- 7 files changed, 110 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e23e230..30115d0e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Spurious "Service temporarily unavailable" after tab idle (#746)** — After leaving the dashboard open in a background tab, stale keep-alive sockets caused the first resume requests to fail; the service worker was turning those into fake `503 Offline` responses (breaking JSON parsing and showing stuck error toasts with invisible Retry/Refresh buttons in light mode). The worker no longer intercepts `/api/*`, remaining background polls (chat widget, activity feed, kanban) are quiet + hidden-gated, error toasts dedupe and use theme-aware button styles, and a later resume health sweep clears leftover connectivity toasts. - **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/static/error-handling-enhanced.js b/app/static/error-handling-enhanced.js index 4efddfcb0..1fa1b3106 100644 --- a/app/static/error-handling-enhanced.js +++ b/app/static/error-handling-enhanced.js @@ -64,6 +64,12 @@ class EnhancedErrorHandler { this.checkOnlineStatus({ quiet: true, onResume: true }); } }, 1500); + // Later sweep: throttled background timers can fire after 1.5s + setTimeout(() => { + if (document.visibilityState === 'visible') { + this.checkOnlineStatus({ quiet: true, onResume: true }); + } + }, 5000); } else { this.stopHealthProbeInterval(); } @@ -278,9 +284,9 @@ class EnhancedErrorHandler { .error-retry-btn { padding: 8px 16px; - background: rgba(255, 255, 255, 0.2); - color: white; - border: 1px solid rgba(255, 255, 255, 0.3); + background: rgba(148, 163, 184, 0.18); + color: inherit; + border: 1px solid rgba(148, 163, 184, 0.35); border-radius: 6px; cursor: pointer; font-size: 0.875rem; @@ -290,7 +296,7 @@ class EnhancedErrorHandler { } .error-retry-btn:hover { - background: rgba(255, 255, 255, 0.3); + background: rgba(148, 163, 184, 0.28); } .error-retry-btn:disabled { @@ -311,9 +317,9 @@ class EnhancedErrorHandler { .error-recovery-btn { padding: 6px 12px; - background: rgba(255, 255, 255, 0.15); - color: white; - border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(148, 163, 184, 0.15); + color: inherit; + border: 1px solid rgba(148, 163, 184, 0.35); border-radius: 6px; cursor: pointer; font-size: 0.875rem; @@ -321,7 +327,7 @@ class EnhancedErrorHandler { } .error-recovery-btn:hover { - background: rgba(255, 255, 255, 0.25); + background: rgba(148, 163, 184, 0.25); } `; document.head.appendChild(style); @@ -385,12 +391,16 @@ class EnhancedErrorHandler { } async handleFetchException(error, url, options) { - // Network error + // Network error — Response status must be 200–599; use 503 + marker header. if (!this.isOnline) { await this.queueForOffline(url, options); return new Response(JSON.stringify({ error: 'Offline' }), { - status: 0, - statusText: 'Offline' + status: 503, + statusText: 'Offline', + headers: { + 'Content-Type': 'application/json', + 'X-TT-Network-Error': '1' + } }); } @@ -400,8 +410,12 @@ class EnhancedErrorHandler { }); return new Response(JSON.stringify({ error: userFriendlyMessage }), { - status: 0, - statusText: 'Network Error' + status: 503, + statusText: 'Network Error', + headers: { + 'Content-Type': 'application/json', + 'X-TT-Network-Error': '1' + } }); } @@ -470,6 +484,12 @@ class EnhancedErrorHandler { * Show Error with Retry Button */ showErrorWithRetry(message, status, retryCallback) { + // Deduplicate burst toasts from parallel failing polls after tab resume + 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..8e8fb1904 100644 --- a/app/static/js/sw.js +++ b/app/static/js/sw.js @@ -1,4 +1,4 @@ -/* TimeTracker service worker — cache static assets; do not touch /api/v1/* (token auth). */ +/* TimeTracker service worker — cache static assets; never intercept /api/* (session + token auth). */ const CACHE_NAME = 'timetracker-v1'; const PRECACHE_URLS = [ @@ -59,7 +59,8 @@ async function cacheFirst(request) { } return response; } catch (e) { - return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); + // Real network error (not a fake 503) so callers / reconnect logic can recover. + return Response.error(); } } @@ -76,14 +77,6 @@ async function networkFirstDocument(request) { } } -async function networkFirstApi(request) { - try { - return await fetch(request); - } catch (_) { - return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); - } -} - self.addEventListener('fetch', (event) => { const { request } = event; if (request.method !== 'GET') { @@ -101,13 +94,10 @@ self.addEventListener('fetch', (event) => { const path = url.pathname; - // Never intercept token-auth API — browser handles the request unchanged. - if (path.startsWith('/api/v1/')) { - return; - } - - // Health probes must hit the network directly (no synthetic 503 on transient fail). - if (path === '/api/health' || path === '/_health') { + // Never intercept API or health probes — let the browser surface genuine network + // failures (stale keep-alive after tab idle) instead of synthesizing 503 Offline. + // Covers /api/*, /api/v1/*, /api/health, and /_health. + if (path.startsWith('/api/') || path === '/_health') { return; } @@ -116,11 +106,6 @@ self.addEventListener('fetch', (event) => { return; } - if (path.startsWith('/api/')) { - event.respondWith(networkFirstApi(request)); - return; - } - if (request.mode === 'navigate' || request.destination === 'document') { event.respondWith(networkFirstDocument(request)); return; diff --git a/app/templates/components/activity_feed_widget.html b/app/templates/components/activity_feed_widget.html index 9a1aac392..8fcc9e729 100644 --- a/app/templates/components/activity_feed_widget.html +++ b/app/templates/components/activity_feed_widget.html @@ -253,7 +253,8 @@

credentials: 'same-origin', headers: { 'Accept': 'application/json' - } + }, + __ttQuiet: true }); if (!response.ok) { console.error('API response not OK:', response.status, response.statusText); @@ -421,8 +422,9 @@

updateFilterIndicators('all'); }); -// Auto-refresh activity feed every 30 seconds +// Auto-refresh activity feed every 30 seconds (skip while tab is hidden — #746) setInterval(() => { + if (document.hidden) return; if (activityPage === 1) { refreshActivityFeed(); } diff --git a/app/templates/components/persistent_chat_widget.html b/app/templates/components/persistent_chat_widget.html index 734f1c880..638cf9ae4 100644 --- a/app/templates/components/persistent_chat_widget.html +++ b/app/templates/components/persistent_chat_widget.html @@ -227,9 +227,15 @@

async function fetchColumnsNoStore(){ const resp = await fetch('/api/kanban/columns?_=' + Date.now(), { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Cache-Control': 'no-cache' }, - cache: 'no-store' + cache: 'no-store', + __ttQuiet: true }); if (!resp.ok) throw new Error('Failed to fetch columns'); const payload = await resp.json(); @@ -1034,9 +1035,10 @@

${col.label}
// Initial bind of drag/drop now that function exists bindDragAndDrop(); - // Background polling fallback to catch missed events + // Background polling fallback to catch missed events (skip while tab is hidden — #746) try { setInterval(async () => { + if (document.hidden) return; try { const columns = await fetchColumnsNoStore(); const sig = computeColumnsSignature(columns); diff --git a/tests/test_reconnect_on_resume.py b/tests/test_reconnect_on_resume.py index cb4409d69..c0707f2c2 100644 --- a/tests/test_reconnect_on_resume.py +++ b/tests/test_reconnect_on_resume.py @@ -1,5 +1,5 @@ """ -Regression guards for reconnect-on-resume (#702 / #703). +Regression guards for reconnect-on-resume (#702 / #703 / #746). After the UI is backgrounded (tray-hide or inactive tab), a transient network failure must not stick the client in an unavailable state. These tests assert @@ -27,10 +27,11 @@ def test_error_handler_quiet_health_and_visibility_reconnect(): assert "startHealthProbeInterval" in content assert "/api/health" in content assert "if (quiet)" in content - # Delayed second probe on resume sweeps errors from background fetches (#703 redux) + # Delayed probes on resume sweep errors from background fetches (#703 / #746) assert "setTimeout" in content assert "1500" in content - assert content.count("checkOnlineStatus({ quiet: true, onResume: true })") >= 2 + assert "5000" in content + assert content.count("checkOnlineStatus({ quiet: true, onResume: true })") >= 3 def test_idle_background_fetches_are_quiet(): @@ -58,10 +59,46 @@ def test_smart_notifications_poll_is_quiet(): assert "__ttQuiet: true" in content assert "document.hidden" in content -def test_service_worker_bypasses_health_probe(): + +def test_service_worker_does_not_synthesize_api_offline(): + """SW must not intercept /api/ or invent 503 Offline bodies (#746).""" content = (STATIC / "js" / "sw.js").read_text(encoding="utf-8") - assert "path === '/api/health'" in content + assert "networkFirstApi" not in content + assert "new Response('Offline'" not in content + assert "path.startsWith('/api/')" in content assert "path === '/_health'" in content + # cacheFirst uses Response.error() instead of a fake 503 + assert "Response.error()" in content + + +def test_error_handler_no_invalid_status_zero_and_visible_retry_buttons(): + """#746: Response status 0 is invalid; retry buttons must not be white-on-white.""" + content = (STATIC / "error-handling-enhanced.js").read_text(encoding="utf-8") + assert "status: 0" not in content + assert "X-TT-Network-Error" in content + # Retry/recovery buttons on toasts use inherit + slate, not white-on-white + assert ".error-retry-btn" in content + assert "color: inherit" in content + assert "rgba(148, 163, 184" in content + assert "color: white;\n border: 1px solid rgba(255, 255, 255" not in content + # Burst toast dedup in showErrorWithRetry + assert "Duplicate error suppressed" in content + + +def test_remaining_background_polls_are_quiet(): + """Chat widget, activity feed, and kanban polls must be quiet + hidden-gated (#746).""" + chat = (TEMPLATES / "components" / "persistent_chat_widget.html").read_text(encoding="utf-8") + assert "__ttQuiet: true" in chat + assert "document.hidden" in chat + assert "response.ok" in chat + + activity = (TEMPLATES / "components" / "activity_feed_widget.html").read_text(encoding="utf-8") + assert "__ttQuiet: true" in activity + assert "document.hidden" in activity + + kanban = (TEMPLATES / "tasks" / "_kanban.html").read_text(encoding="utf-8") + assert "__ttQuiet: true" in kanban + assert "document.hidden" in kanban def test_base_template_reconnects_socket_on_visible(): From 632d9fd2751c571405af41bc1bd51568c2aa910a Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 2 Sep 2026 19:51:08 +0200 Subject: [PATCH 3/5] fix(idle): enforce Still working? timeout across clients (#722) Stop forgotten timers from running for hours by fixing extension heartbeats that defeated the server safety net on unreliable chrome.idle, adding FCM wake-up for backgrounded mobile, and hardening the idle scheduler. --- app/__init__.py | 8 + app/config.py | 4 + app/models/push_subscription.py | 62 +++++++- app/routes/push_notifications.py | 68 ++++++++- app/services/time_tracking_service.py | 1 + app/utils/firebase_push.py | 144 ++++++++++++++++++ app/utils/scheduled_tasks.py | 132 ++++++++++++---- browser-extension/background.js | 63 +++++++- env.example | 4 + .../183_add_device_token_push_subscription.py | 76 +++++++++ .../core/services/idle_detection_service.dart | 16 +- .../core/services/notification_service.dart | 142 +++++++++++++++++ mobile/lib/data/api/api_client.dart | 21 +++ mobile/lib/main.dart | 13 ++ .../presentation/screens/login_screen.dart | 6 + .../presentation/screens/splash_screen.dart | 16 ++ mobile/pubspec.yaml | 6 +- requirements.txt | 3 + tests/test_api_v1.py | 33 ++++ 19 files changed, 768 insertions(+), 50 deletions(-) create mode 100644 app/utils/firebase_push.py create mode 100644 migrations/versions/183_add_device_token_push_subscription.py diff --git a/app/__init__.py b/app/__init__.py index b06771654..afc685b9e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -268,6 +268,14 @@ def create_app(config=None): socketio.init_app(app, cors_allowed_origins="*") oauth.init_app(app) + # Optional Firebase Admin for FCM mobile idle wake-up (Issue #722). No-op when unset. + try: + from app.utils.firebase_push import init_firebase_admin + + init_firebase_admin(app) + except Exception as e: + logger.debug("Firebase Admin init skipped: %s", e) + # Fast-path for migration/bootstrap runs: # we only need config + db/migrate + models loaded. Avoid registering routes, # background jobs, integrations, etc. to keep migrations as fast and reliable diff --git a/app/config.py b/app/config.py index 05a0b4d07..36b52abe9 100644 --- a/app/config.py +++ b/app/config.py @@ -52,6 +52,10 @@ class Config: SINGLE_ACTIVE_TIMER = os.getenv("SINGLE_ACTIVE_TIMER", "true").lower() == "true" IDLE_TIMEOUT_MINUTES = int(os.getenv("IDLE_TIMEOUT_MINUTES", 30)) + # Firebase Cloud Messaging (optional; mobile idle "Still working?" wake-up, Issue #722). + # Accepts either a path to a service-account JSON file or the raw JSON string. + FIREBASE_SERVICE_ACCOUNT_JSON = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON", "").strip() + # 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/push_subscription.py b/app/models/push_subscription.py index 91258bc9d..171b2fdf1 100644 --- a/app/models/push_subscription.py +++ b/app/models/push_subscription.py @@ -1,5 +1,5 @@ """ -Push Subscription model for storing browser push notification subscriptions. +Push Subscription model for storing browser push and mobile FCM device tokens. """ import json @@ -10,17 +10,22 @@ class PushSubscription(db.Model): - """Model for storing browser push notification subscriptions""" + """Model for storing browser Web Push and mobile FCM subscriptions.""" __tablename__ = "push_subscriptions" id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - # Push subscription data (JSON format from browser Push API) + # Push subscription data (JSON format from browser Push API). + # For FCM device rows, endpoint is a sentinel ``fcm://`` and keys is {}. endpoint = db.Column(db.Text, nullable=False) # Push service endpoint URL keys = db.Column(db.JSON, nullable=False) # p256dh and auth keys + # Mobile FCM (Issue #722 idle wake-up) + device_token = db.Column(db.String(512), nullable=True, index=True) + platform = db.Column(db.String(20), nullable=True) # 'android', 'ios', 'web' + # Metadata user_agent = db.Column(db.String(500), nullable=True) # Browser user agent created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) @@ -30,12 +35,14 @@ class PushSubscription(db.Model): # Relationships user = db.relationship("User", backref="push_subscriptions", lazy="joined") - def __init__(self, user_id, endpoint, keys, user_agent=None): + def __init__(self, user_id, endpoint, keys, user_agent=None, device_token=None, platform=None): """Create a push subscription""" self.user_id = user_id self.endpoint = endpoint self.keys = keys if isinstance(keys, dict) else json.loads(keys) if isinstance(keys, str) else {} self.user_agent = user_agent + self.device_token = device_token + self.platform = platform def __repr__(self): return f"" @@ -47,6 +54,8 @@ def to_dict(self): "user_id": self.user_id, "endpoint": self.endpoint, "keys": self.keys, + "device_token": self.device_token, + "platform": self.platform, "user_agent": self.user_agent, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, @@ -68,3 +77,48 @@ def get_user_subscriptions(cls, user_id): def find_by_endpoint(cls, user_id, endpoint): """Find a subscription by user and endpoint""" return cls.query.filter_by(user_id=user_id, endpoint=endpoint).first() + + @classmethod + def find_by_device_token(cls, user_id, device_token): + """Find a mobile FCM subscription by user and device token.""" + if not device_token: + return None + return cls.query.filter_by(user_id=user_id, device_token=device_token).first() + + @classmethod + def upsert_device_token(cls, user_id, device_token, platform, user_agent=None): + """Create or update an FCM device-token subscription for a user.""" + platform_norm = (platform or "").strip().lower() or None + if platform_norm not in ("android", "ios"): + platform_norm = platform_norm if platform_norm else "android" + + existing = cls.find_by_device_token(user_id, device_token) + if existing: + existing.platform = platform_norm + existing.user_agent = user_agent + existing.updated_at = now_in_app_timezone() + existing.last_used_at = now_in_app_timezone() + return existing + + # Sentinel endpoint keeps the non-null unique-ish endpoint column valid for FCM rows. + endpoint = f"fcm://{device_token}" + collision = cls.find_by_endpoint(user_id, endpoint) + if collision: + collision.device_token = device_token + collision.platform = platform_norm + collision.keys = collision.keys or {} + collision.user_agent = user_agent + collision.updated_at = now_in_app_timezone() + collision.last_used_at = now_in_app_timezone() + return collision + + sub = cls( + user_id=user_id, + endpoint=endpoint, + keys={}, + user_agent=user_agent, + device_token=device_token, + platform=platform_norm, + ) + db.session.add(sub) + return sub diff --git a/app/routes/push_notifications.py b/app/routes/push_notifications.py index 99c63ea52..94f94b4e1 100644 --- a/app/routes/push_notifications.py +++ b/app/routes/push_notifications.py @@ -2,15 +2,14 @@ Routes for push notification management. """ -import json - -from flask import Blueprint, jsonify, request -from flask_babel import gettext as _ +from flask import Blueprint, current_app, g, jsonify, request from flask_login import current_user, login_required from app import db -from app.models import PushSubscription, User +from app.models import PushSubscription +from app.utils.api_auth import require_api_token from app.utils.db import safe_commit +from app.utils.timezone import now_in_app_timezone push_bp = Blueprint("push", __name__) @@ -40,14 +39,17 @@ def subscribe_push(): # Update existing subscription existing.keys = keys existing.user_agent = user_agent - from app.utils.timezone import now_in_app_timezone - + existing.platform = existing.platform or "web" existing.updated_at = now_in_app_timezone() existing.update_last_used() else: # Create new subscription subscription = PushSubscription( - user_id=current_user.id, endpoint=endpoint, keys=keys, user_agent=user_agent + user_id=current_user.id, + endpoint=endpoint, + keys=keys, + user_agent=user_agent, + platform="web", ) db.session.add(subscription) @@ -101,3 +103,53 @@ def list_subscriptions(): return jsonify({"success": True, "subscriptions": [sub.to_dict() for sub in subscriptions]}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 + + +def _register_device_token_for_user(user_id: int): + """Shared handler for session and API-token device registration.""" + data = request.get_json(silent=True) or {} + device_token = (data.get("device_token") or data.get("token") or "").strip() + platform = (data.get("platform") or "").strip().lower() + user_agent = request.headers.get("User-Agent", "") + + if not device_token: + return jsonify({"success": False, "error": "device_token is required"}), 400 + if platform and platform not in ("android", "ios"): + return jsonify({"success": False, "error": "platform must be android or ios"}), 400 + if not platform: + platform = "android" + + if len(device_token) > 512: + return jsonify({"success": False, "error": "device_token too long"}), 400 + + try: + PushSubscription.upsert_device_token( + user_id=user_id, + device_token=device_token, + platform=platform, + user_agent=user_agent, + ) + if safe_commit("register_device_push", {"user_id": user_id}): + return jsonify({"success": True, "message": "Device registered for push"}) + return jsonify({"success": False, "error": "Failed to save device token"}), 500 + except Exception as e: + db.session.rollback() + current_app.logger.warning("register device push failed: %s", e) + return jsonify({"success": False, "error": str(e)}), 500 + + +@push_bp.route("/api/push/register-device", methods=["POST"]) +@login_required +def register_device_session(): + """Register an FCM device token (session auth).""" + return _register_device_token_for_user(current_user.id) + + +@push_bp.route("/api/v1/push/register-device", methods=["POST"]) +@require_api_token("write:time_entries") +def register_device_api(): + """Register an FCM device token (API token auth for mobile/desktop).""" + user = getattr(g, "api_user", None) + if user is None: + return jsonify({"success": False, "error": "Unauthorized"}), 401 + return _register_device_token_for_user(user.id) diff --git a/app/services/time_tracking_service.py b/app/services/time_tracking_service.py index a00595d59..f26162a66 100644 --- a/app/services/time_tracking_service.py +++ b/app/services/time_tracking_service.py @@ -228,6 +228,7 @@ def stop_timer( # Stop the timer entry.end_time = stop_at + entry.idle_notified_at = None entry.calculate_duration() if not safe_commit("stop_timer", {"user_id": user_id, "entry_id": entry.id}): diff --git a/app/utils/firebase_push.py b/app/utils/firebase_push.py new file mode 100644 index 000000000..cfc6dbad9 --- /dev/null +++ b/app/utils/firebase_push.py @@ -0,0 +1,144 @@ +"""Optional Firebase Cloud Messaging helpers for mobile idle wake-up (Issue #722). + +Configured via ``FIREBASE_SERVICE_ACCOUNT_JSON`` (raw JSON string or path to a +service-account JSON file). When unset or invalid, FCM is a no-op so self-hosted +installs without Firebase keep working. +""" + +from __future__ import annotations + +import json +import logging +import os +from typing import List, Optional, Sequence, Tuple + +logger = logging.getLogger(__name__) + +_firebase_app = None +_init_attempted = False + + +def init_firebase_admin(app=None) -> bool: + """Initialize firebase_admin once from app config / env. Returns True if ready.""" + global _firebase_app, _init_attempted + if _firebase_app is not None: + return True + if _init_attempted and _firebase_app is None: + return False + _init_attempted = True + + raw = "" + if app is not None: + raw = (app.config.get("FIREBASE_SERVICE_ACCOUNT_JSON") or "").strip() + if not raw: + raw = (os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON") or "").strip() + if not raw: + logger.debug("FIREBASE_SERVICE_ACCOUNT_JSON not set; FCM disabled") + return False + + try: + import firebase_admin + from firebase_admin import credentials + except Exception as e: + logger.debug("firebase_admin not installed; FCM disabled: %s", e) + return False + + try: + if firebase_admin._apps: # already initialized elsewhere + _firebase_app = firebase_admin.get_app() + return True + except Exception: + pass + + try: + cred_dict = None + if raw.startswith("{"): + cred_dict = json.loads(raw) + elif os.path.isfile(raw): + with open(raw, "r", encoding="utf-8") as fh: + cred_dict = json.load(fh) + else: + logger.warning("FIREBASE_SERVICE_ACCOUNT_JSON is neither JSON nor a readable file path") + return False + + cred = credentials.Certificate(cred_dict) + _firebase_app = firebase_admin.initialize_app(cred) + logger.info("Firebase Admin initialized for FCM") + return True + except Exception as e: + logger.warning("Failed to initialize Firebase Admin: %s", e) + _firebase_app = None + return False + + +def send_fcm_to_tokens( + tokens: Sequence[str], + *, + title: str, + body: str, + data: Optional[dict] = None, +) -> Tuple[int, List[str]]: + """Send a notification+data FCM message to each token. + + Returns ``(delivered_count, invalid_tokens)``. Invalid tokens should be removed + from the database by the caller. + """ + if not tokens: + return 0, [] + if not init_firebase_admin(): + return 0, [] + + try: + from firebase_admin import messaging + except Exception as e: + logger.debug("firebase_admin.messaging unavailable: %s", e) + return 0, [] + + delivered = 0 + invalid: List[str] = [] + payload_data = {str(k): str(v) for k, v in (data or {}).items() if v is not None} + + for token in tokens: + if not token: + continue + try: + message = messaging.Message( + token=token, + notification=messaging.Notification(title=title, body=body), + data=payload_data, + android=messaging.AndroidConfig( + priority="high", + notification=messaging.AndroidNotification( + channel_id="idle_reminder", + priority="high", + ), + ), + apns=messaging.APNSConfig( + headers={"apns-priority": "10"}, + payload=messaging.APNSPayload( + aps=messaging.Aps( + alert=messaging.ApsAlert(title=title, body=body), + sound="default", + content_available=True, + ) + ), + ), + ) + messaging.send(message) + delivered += 1 + except Exception as e: + err_name = type(e).__name__ + err_text = str(e).lower() + # Unregistered / invalid token — tell caller to drop it + if ( + "unregistered" in err_text + or "not-found" in err_text + or "invalid-argument" in err_text + or "registration-token-not-registered" in err_text + or err_name in ("UnregisteredError", "SenderIdMismatchError", "NotFoundError") + ): + invalid.append(token) + else: + logger.debug("FCM send to token failed: %s", e) + + return delivered, invalid diff --git a/app/utils/scheduled_tasks.py b/app/utils/scheduled_tasks.py index 93a105b68..e41fcdecc 100644 --- a/app/utils/scheduled_tasks.py +++ b/app/utils/scheduled_tasks.py @@ -897,8 +897,8 @@ def _otel_apscheduler_listener(event): def check_idle_timers(): """Server-side idle timeout safety net. - For every active timer whose last heartbeat (or start time if never heartbeated) - is older than ``settings.idle_timeout_minutes``: + For every active (non-paused) timer whose last heartbeat (or start time if never + heartbeated) 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 @@ -907,6 +907,9 @@ def check_idle_timers(): Clients that keep sending heartbeats keep ``last_heartbeat_at`` fresh and clear ``idle_notified_at``, so they are never auto-stopped while active. + + Each entry is processed in its own mini-transaction so a failure on one timer + cannot roll back ``idle_notified_at`` already committed for others. """ from app.models import Settings from app.models.time_entry import local_now @@ -920,30 +923,40 @@ def check_idle_timers(): now = local_now() cutoff = now - threshold - stale = ( - TimeEntry.query.filter( - TimeEntry.end_time.is_(None), - db.or_( - TimeEntry.last_heartbeat_at < cutoff, - db.and_( - TimeEntry.last_heartbeat_at.is_(None), - TimeEntry.start_time < cutoff, + stale_ids = [ + row[0] + for row in ( + db.session.query(TimeEntry.id) + .filter( + TimeEntry.end_time.is_(None), + TimeEntry.paused_at.is_(None), + db.or_( + TimeEntry.last_heartbeat_at < cutoff, + db.and_( + TimeEntry.last_heartbeat_at.is_(None), + TimeEntry.start_time < cutoff, + ), ), - ), + ) + .all() ) - .all() - ) + ] - if not stale: + if not stale_ids: return 0 notified = 0 stopped = 0 - for entry in stale: + for entry_id in stale_ids: try: + entry = db.session.get(TimeEntry, entry_id) + if entry is None or entry.end_time is not None or entry.paused_at is not None: + continue + if entry.idle_notified_at is None: entry.idle_notified_at = now entry.updated_at = now + db.session.commit() _send_idle_push(entry) notified += 1 logger.info( @@ -965,8 +978,6 @@ def check_idle_timers(): 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 @@ -993,16 +1004,13 @@ def check_idle_timers(): except Exception as e: logger.warning( "Idle check failed for entry %s: %s", - getattr(entry, "id", None), + entry_id, e, ) - db.session.rollback() - - try: - db.session.commit() - except Exception as e: - logger.warning("Idle check commit failed: %s", e) - db.session.rollback() + try: + db.session.rollback() + except Exception: + pass if notified or stopped: logger.info("Idle check: notified=%d stopped=%d", notified, stopped) @@ -1023,7 +1031,8 @@ def _send_idle_push(entry): 1. Web PushSubscription (browser) 2. Socket.IO room ``user_`` so open web/desktop clients show the prompt immediately even without a push subscription - 3. Mobile/desktop pick up ``idle_notified`` from ``GET /api/v1/timer/status`` + 3. FCM device tokens (Android/iOS) when registered + 4. Mobile/desktop also pick up ``idle_notified`` from ``GET /api/v1/timer/status`` on their next poll (already wired in IdleDetectionService / desktop idle) """ user = getattr(entry, "user", None) @@ -1053,7 +1062,7 @@ def _send_idle_push(entry): except Exception as e: logger.debug("Idle socket emit failed for user %s: %s", getattr(user, "username", user.id), e) - # Browser Web Push (when VAPID + subscriptions exist) + # Browser Web Push + FCM device tokens try: from app.models import PushSubscription except Exception: @@ -1066,10 +1075,72 @@ def _send_idle_push(entry): if not subscriptions: return + web_subs = [s for s in subscriptions if not getattr(s, "device_token", None)] + fcm_subs = [s for s in subscriptions if getattr(s, "device_token", None)] + + if web_subs: + try: + _deliver_push_to_subscriptions(user, web_subs, note) + except Exception as e: + logger.debug("Idle web push failed for user %s: %s", getattr(user, "username", user.id), e) + + if fcm_subs: + try: + _deliver_fcm_to_devices(user, fcm_subs, note) + except Exception as e: + logger.debug("Idle FCM push failed for user %s: %s", getattr(user, "username", user.id), e) + + +def _deliver_fcm_to_devices(user, subscriptions, note) -> int: + """Send an FCM data+notification message to registered mobile device tokens.""" + try: + from app.utils.firebase_push import send_fcm_to_tokens + except Exception: + logger.debug("firebase_push helper unavailable; skipping FCM for %s", getattr(user, "username", user.id)) + return 0 + + tokens = [s.device_token for s in subscriptions if s.device_token] + if not tokens: + return 0 + + data = { + "kind": str(note.get("kind") or "idle_timeout"), + "title": str(note.get("title") or "Still working?"), + "message": str(note.get("message") or ""), + "timer_id": str(note.get("timer_id") or ""), + "idle_notified_at": str(note.get("idle_notified_at") or ""), + } try: - _deliver_push_to_subscriptions(user, subscriptions, note) + delivered, invalid = send_fcm_to_tokens( + tokens, + title=data["title"], + body=data["message"], + data=data, + ) except Exception as e: - logger.debug("Idle push failed for user %s: %s", getattr(user, "username", user.id), e) + logger.debug("FCM send failed for %s: %s", getattr(user, "username", user.id), e) + return 0 + + if invalid: + for sub in subscriptions: + if sub.device_token in invalid: + try: + db.session.delete(sub) + except Exception: + pass + try: + db.session.commit() + except Exception: + db.session.rollback() + + for sub in subscriptions: + if sub.device_token and sub.device_token not in invalid: + try: + sub.update_last_used() + except Exception: + pass + + return delivered def send_smart_reminder_push_notifications(): @@ -1174,6 +1245,9 @@ def _deliver_push_to_subscriptions(user, subscriptions, note) -> int: } delivered = 0 for sub in subscriptions: + # Skip FCM device rows (handled by _deliver_fcm_to_devices). + if getattr(sub, "device_token", None) or str(getattr(sub, "endpoint", "") or "").startswith("fcm://"): + continue try: webpush( subscription_info={"endpoint": sub.endpoint, "keys": sub.keys or {}}, diff --git a/browser-extension/background.js b/browser-extension/background.js index 8b4701951..5a30ae266 100644 --- a/browser-extension/background.js +++ b/browser-extension/background.js @@ -92,6 +92,11 @@ async function clearIdleGraceState() { await chrome.storage.local.remove(['idle_grace_stop_at', 'idle_grace_active']); } +/** Record positive user/OS activity so poll heartbeats stay honest when chrome.idle is unreliable. */ +async function markPositiveActivity(atMs = Date.now()) { + await chrome.storage.local.set({ last_positive_activity_at: atMs }); +} + async function beginIdleGrace(stopAtMs) { const { last_timer_status } = await chrome.storage.local.get('last_timer_status'); if (!last_timer_status?.active || !last_timer_status?.timer) { @@ -103,6 +108,8 @@ async function beginIdleGrace(stopAtMs) { return; } + // Entering grace must stop heartbeats immediately so the server 5-minute + // window can advance (Issue #722 — poll heartbeats otherwise defeat it). await chrome.storage.local.set({ idle_grace_active: true, idle_grace_stop_at: stopAtMs, @@ -147,6 +154,7 @@ async function sendServerHeartbeat() { async function confirmStillWorking() { await clearIdleGraceState(); + await markPositiveActivity(); await sendServerHeartbeat(); } @@ -191,6 +199,20 @@ async function refreshTimerStatus({ force = false } = {}) { if (active) { setRunningUi(status.timer); + + const stored = await chrome.storage.local.get([ + 'idle_grace_active', + 'last_positive_activity_at', + 'last_timer_status', + 'idle_api_trusted', + ]); + // First time we see an active timer (or timer id change), seed activity. + const prevId = stored.last_timer_status?.timer?.id; + const curId = status.timer?.id; + if (!stored.last_positive_activity_at || prevId !== curId) { + await markPositiveActivity(); + } + // Server already marked this timer idle (#722) — enter the same grace // window the local chrome.idle path uses, even if OS idle has not fired. const idleNotified = Boolean( @@ -201,10 +223,19 @@ async function refreshTimerStatus({ force = false } = {}) { // when the server just notified, not at last_active alone (0 min). await beginIdleGrace(Date.now()); } - // 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'); - if (!idle_grace_active) { + + const idleThresholdMs = idleTimeoutMinutes * 60 * 1000; + const graceActive = Boolean( + (await chrome.storage.local.get('idle_grace_active')).idle_grace_active + ); + + // Heartbeat policy (Issue #722): + // - If chrome.idle has proven it can detect idle/locked, trust queryState + // and keep heartbeats flowing while the OS reports active. + // - If never proven (common on Linux/Wayland where queryState sticks on + // "active"), only heartbeat within the positive-activity window so the + // server safety net can still fire after idle_timeout. + if (!graceActive) { try { const idleState = await new Promise((resolve) => { try { @@ -214,7 +245,21 @@ async function refreshTimerStatus({ force = false } = {}) { } }); if (idleState === 'active') { - await client.sendHeartbeat(); + const { last_positive_activity_at, idle_api_trusted } = + await chrome.storage.local.get([ + 'last_positive_activity_at', + 'idle_api_trusted', + ]); + const lastActivity = Number(last_positive_activity_at) || 0; + const withinWindow = Date.now() - lastActivity < idleThresholdMs; + if (idle_api_trusted) { + await markPositiveActivity(); + await client.sendHeartbeat(); + } else if (withinWindow) { + await client.sendHeartbeat(); + } else { + await beginIdleGrace(Date.now()); + } } } catch (hbErr) { console.debug('[TimeTracker] poll heartbeat failed:', hbErr); @@ -288,7 +333,9 @@ chrome.storage.onChanged.addListener((changes, area) => { chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message?.type === 'refresh_timer') { - refreshTimerStatus({ force: true }) + // Opening the popup is positive user activity. + markPositiveActivity() + .then(() => refreshTimerStatus({ force: true })) .then((result) => sendResponse({ ok: true, ...result })) .catch((error) => sendResponse({ ok: false, error: error.message })); return true; @@ -319,6 +366,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 markPositiveActivity(); await sendServerHeartbeat(); return; } @@ -326,6 +374,9 @@ chrome.idle.onStateChanged.addListener(async (newState) => { return; } + // chrome.idle successfully detected idle/locked — trust it for future polls. + await chrome.storage.local.set({ idle_api_trusted: true }); + const { last_timer_status } = await chrome.storage.local.get([ 'last_timer_status', ]); diff --git a/env.example b/env.example index 9d844d1b2..70539362a 100644 --- a/env.example +++ b/env.example @@ -42,6 +42,10 @@ ROUNDING_ENFORCE_GLOBAL=false SINGLE_ACTIVE_TIMER=true IDLE_TIMEOUT_MINUTES=30 +# Optional: Firebase Cloud Messaging for mobile idle "Still working?" wake-up (Issue #722). +# Path to a service-account JSON file, or the raw JSON string. +# FIREBASE_SERVICE_ACCOUNT_JSON=/run/secrets/firebase-service-account.json + # Smart in-app notifications (session toasts; see docs/features/SMART_NOTIFICATIONS.md) # SMART_NOTIFY_MAX_PER_DAY=2 # SMART_NOTIFY_NO_TRACKING_AFTER=16:00 diff --git a/migrations/versions/183_add_device_token_push_subscription.py b/migrations/versions/183_add_device_token_push_subscription.py new file mode 100644 index 000000000..9447df129 --- /dev/null +++ b/migrations/versions/183_add_device_token_push_subscription.py @@ -0,0 +1,76 @@ +"""Add device_token and platform to push_subscriptions for FCM (Issue #722). + +Revision ID: 183_add_device_token_push_subscription +Revises: 182_add_project_last_used_at +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "183_add_device_token_push_subscription" +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 _has_index(inspector, table_name: str, index_name: str) -> bool: + try: + if table_name not in inspector.get_table_names(): + return False + return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name)) + except Exception: + return False + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "push_subscriptions" not in inspector.get_table_names(): + return + + if not _has_column(inspector, "push_subscriptions", "device_token"): + op.add_column( + "push_subscriptions", + sa.Column("device_token", sa.String(length=512), nullable=True), + ) + if not _has_column(inspector, "push_subscriptions", "platform"): + op.add_column( + "push_subscriptions", + sa.Column("platform", sa.String(length=20), nullable=True), + ) + + inspector = inspect(bind) + if not _has_index(inspector, "push_subscriptions", "ix_push_subscriptions_device_token"): + try: + op.create_index( + "ix_push_subscriptions_device_token", + "push_subscriptions", + ["device_token"], + unique=False, + ) + except Exception: + pass + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "push_subscriptions" not in inspector.get_table_names(): + return + if _has_index(inspector, "push_subscriptions", "ix_push_subscriptions_device_token"): + try: + op.drop_index("ix_push_subscriptions_device_token", table_name="push_subscriptions") + except Exception: + pass + if _has_column(inspector, "push_subscriptions", "platform"): + op.drop_column("push_subscriptions", "platform") + if _has_column(inspector, "push_subscriptions", "device_token"): + op.drop_column("push_subscriptions", "device_token") diff --git a/mobile/lib/core/services/idle_detection_service.dart b/mobile/lib/core/services/idle_detection_service.dart index 2222d2543..ce2941137 100644 --- a/mobile/lib/core/services/idle_detection_service.dart +++ b/mobile/lib/core/services/idle_detection_service.dart @@ -16,8 +16,11 @@ import 'package:timetracker_mobile/domain/repositories/time_tracking_repository. /// /// When the app is backgrounded, polls the server for ``idle_notified`` (nudged /// by the Android foreground task) so the local notification still fires. -/// When the app is killed, the server-side `check_idle_timers` job is the -/// safety net (requires heartbeats to have been flowing while the app was open). +/// When Firebase is configured, an FCM ``idle_timeout`` data message wakes the +/// app and starts the same grace window (Issue #722). +/// When the app is killed and FCM is unavailable, the server-side +/// `check_idle_timers` job is the safety net (requires heartbeats to have been +/// flowing while the app was open). class IdleDetectionService with WidgetsBindingObserver { IdleDetectionService._(); @@ -57,6 +60,7 @@ class IdleDetectionService with WidgetsBindingObserver { Timer.periodic(heartbeatInterval, (_) => _sendHeartbeat()); _checkTimer = Timer.periodic(checkInterval, (_) => _tick()); NotificationService.instance.onIdleAction = respondToIdlePrompt; + NotificationService.instance.onIdlePush = _onIdlePushFromServer; _registerForegroundTaskCallback(); } @@ -73,6 +77,7 @@ class IdleDetectionService with WidgetsBindingObserver { _graceTimer = null; _promptShown = false; NotificationService.instance.onIdleAction = null; + NotificationService.instance.onIdlePush = null; } void setRepository(TimeTrackingRepository? repository) { @@ -107,6 +112,13 @@ class IdleDetectionService with WidgetsBindingObserver { _lastActivity = DateTime.now(); } + /// Server FCM idle_timeout wake-up (Issue #722): stop heartbeats and start grace. + void _onIdlePushFromServer(Map data) { + if (!_timerActive || _promptShown) return; + _idleStopAt = _creditedStopAt(_lastActivity); + unawaited(_showPrompt()); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { diff --git a/mobile/lib/core/services/notification_service.dart b/mobile/lib/core/services/notification_service.dart index b4c7cc48f..af509c4b7 100644 --- a/mobile/lib/core/services/notification_service.dart +++ b/mobile/lib/core/services/notification_service.dart @@ -1,5 +1,8 @@ +import 'dart:async'; import 'dart:io'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -7,9 +10,57 @@ import 'package:intl/intl.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:timetracker_mobile/core/constants/app_constants.dart'; import 'package:timetracker_mobile/core/services/foreground_task_handler.dart'; +import 'package:timetracker_mobile/data/api/api_client.dart'; enum IdlePromptAction { stillWorking, stop } +/// Top-level FCM background handler (must be a top-level or static function). +@pragma('vm:entry-point') +Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { + // Background isolate — show a local idle prompt when the server wakes us. + try { + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp(); + } + } catch (_) { + // Firebase not configured for this build. + return; + } + final kind = message.data['kind'] ?? message.data['type']; + if (kind != 'idle_timeout') return; + + final plugin = FlutterLocalNotificationsPlugin(); + const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosSettings = DarwinInitializationSettings(); + await plugin.initialize( + const InitializationSettings(android: androidSettings, iOS: iosSettings), + ); + await plugin.show( + AppConstants.notificationIdleReminder, + message.notification?.title ?? message.data['title'] ?? 'Still working?', + message.notification?.body ?? + message.data['message'] ?? + 'Your timer has been idle. Confirm you are still working or it will stop automatically.', + const NotificationDetails( + android: AndroidNotificationDetails( + AppConstants.idleReminderChannelId, + AppConstants.idleReminderChannelName, + channelDescription: AppConstants.idleReminderChannelDescription, + importance: Importance.high, + priority: Priority.high, + category: AndroidNotificationCategory.alarm, + ), + iOS: DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + interruptionLevel: InterruptionLevel.timeSensitive, + ), + ), + payload: 'idle_prompt', + ); +} + /// Persistent "timer running" notification for Android (foreground service) /// and iOS (local notification with start time). class NotificationService { @@ -23,11 +74,19 @@ class NotificationService { bool _initialized = false; bool _isShowing = false; bool _idlePromptShowing = false; + bool _firebaseReady = false; + StreamSubscription? _onMessageSub; + StreamSubscription? _onOpenedSub; + StreamSubscription? _tokenRefreshSub; /// Callback when the user taps Yes/No on the idle prompt notification. void Function(IdlePromptAction action)? onIdleAction; + /// Called when an FCM idle_timeout data message arrives (foreground/background open). + void Function(Map data)? onIdlePush; + bool get isShowing => _isShowing; + bool get firebaseReady => _firebaseReady; /// Call once at app startup (before [runApp]). Future initialize() async { @@ -43,6 +102,7 @@ class NotificationService { await _initLocalNotifications(); await _requestPermissions(); + await _initFirebaseMessaging(); } catch (e, st) { debugPrint('NotificationService.initialize failed: $e\n$st'); } @@ -50,6 +110,88 @@ class NotificationService { _initialized = true; } + Future _initFirebaseMessaging() async { + try { + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp(); + } + + final messaging = FirebaseMessaging.instance; + await messaging.requestPermission( + alert: true, + badge: true, + sound: true, + ); + + _onMessageSub?.cancel(); + _onMessageSub = FirebaseMessaging.onMessage.listen(_handleRemoteMessage); + + _onOpenedSub?.cancel(); + _onOpenedSub = + FirebaseMessaging.onMessageOpenedApp.listen(_handleRemoteMessage); + + // Cold-start from a notification tap. + final initial = await messaging.getInitialMessage(); + if (initial != null) { + _handleRemoteMessage(initial); + } + + _firebaseReady = true; + debugPrint('NotificationService: Firebase Messaging ready'); + } catch (e) { + // Expected when google-services.json / GoogleService-Info.plist is absent. + _firebaseReady = false; + debugPrint('NotificationService: Firebase Messaging unavailable: $e'); + } + } + + void _handleRemoteMessage(RemoteMessage message) { + final kind = message.data['kind'] ?? message.data['type']; + if (kind == 'idle_timeout') { + onIdlePush?.call(Map.from(message.data)); + // Also surface a local notification if one was not already shown. + unawaited( + showIdlePrompt(graceMinutes: 5), + ); + } + } + + /// Obtain the FCM token and POST it to the TimeTracker server. + /// + /// No-ops when Firebase is not configured. Safe to call after every login. + Future registerDeviceToken(ApiClient apiClient) async { + if (!_initialized) { + await initialize(); + } + if (!_firebaseReady) return; + + try { + final messaging = FirebaseMessaging.instance; + final token = await messaging.getToken(); + if (token == null || token.isEmpty) return; + + final platform = Platform.isIOS ? 'ios' : 'android'; + await apiClient.registerDevicePush( + deviceToken: token, + platform: platform, + ); + + _tokenRefreshSub?.cancel(); + _tokenRefreshSub = messaging.onTokenRefresh.listen((newToken) async { + try { + await apiClient.registerDevicePush( + deviceToken: newToken, + platform: platform, + ); + } catch (e) { + debugPrint('FCM token refresh register failed: $e'); + } + }); + } catch (e) { + debugPrint('NotificationService.registerDeviceToken failed: $e'); + } + } + Future _initAndroidForegroundTask() async { FlutterForegroundTask.init( androidNotificationOptions: AndroidNotificationOptions( diff --git a/mobile/lib/data/api/api_client.dart b/mobile/lib/data/api/api_client.dart index 7647395f4..1951460f5 100644 --- a/mobile/lib/data/api/api_client.dart +++ b/mobile/lib/data/api/api_client.dart @@ -116,6 +116,27 @@ class ApiClient { ); } + /// Register this device for FCM idle wake-up (Issue #722). + Future registerDevicePush({ + required String deviceToken, + required String platform, + }) async { + final res = await _dio.post>( + '/api/v1/push/register-device', + data: { + 'device_token': deviceToken, + 'platform': platform, + }, + ); + final code = res.statusCode ?? 0; + if (code >= 200 && code < 300) return true; + throw DioException( + requestOptions: res.requestOptions, + response: res, + type: DioExceptionType.badResponse, + ); + } + Future> pauseTimer() async { final res = await _dio.post>('/api/v1/timer/pause'); _throwIfError(res); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c2957e517..6002962d2 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,3 +1,5 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:timetracker_mobile/core/constants/app_constants.dart'; @@ -15,6 +17,17 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await LocalStorage.init(); await initMobileOpenTelemetry(); + + // Optional Firebase — degrade gracefully when google-services.json is absent. + try { + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp(); + } + FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); + } catch (_) { + // Expected for builds without Firebase config. + } + await NotificationService.instance.initialize(); runApp( const ProviderScope( diff --git a/mobile/lib/presentation/screens/login_screen.dart b/mobile/lib/presentation/screens/login_screen.dart index f6d09645e..fb8e1fc77 100644 --- a/mobile/lib/presentation/screens/login_screen.dart +++ b/mobile/lib/presentation/screens/login_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:timetracker_mobile/core/config/app_config.dart'; import 'package:timetracker_mobile/core/constants/app_constants.dart'; +import 'package:timetracker_mobile/core/services/notification_service.dart'; import 'package:timetracker_mobile/core/telemetry/mobile_otel.dart'; import 'package:timetracker_mobile/data/api/api_client.dart'; import 'package:timetracker_mobile/domain/usecases/sync_usecase.dart'; @@ -154,6 +155,11 @@ class _LoginScreenState extends ConsumerState { // Kick off background periodic sync after a successful login. unawaited(SyncUseCase.shared.startPeriodicSync()); + // Register FCM device token for idle wake-up when Firebase is configured. + unawaited( + NotificationService.instance.registerDeviceToken(apiClient), + ); + if (mounted) { Navigator.of(context).pushReplacementNamed(AppConstants.routeHome); } diff --git a/mobile/lib/presentation/screens/splash_screen.dart b/mobile/lib/presentation/screens/splash_screen.dart index af5220875..20ad12d7c 100644 --- a/mobile/lib/presentation/screens/splash_screen.dart +++ b/mobile/lib/presentation/screens/splash_screen.dart @@ -1,8 +1,12 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../../core/config/app_config.dart'; import '../../core/constants/app_constants.dart'; +import '../../core/services/notification_service.dart'; +import '../../data/api/api_client.dart'; import '../../domain/usecases/sync_usecase.dart'; class SplashScreen extends ConsumerStatefulWidget { @@ -35,6 +39,18 @@ class _SplashScreenState extends ConsumerState { if (route == AppConstants.routeHome) { // ignore: unawaited_futures SyncUseCase.shared.startPeriodicSync(); + // Re-register FCM token on cold start when already logged in (Issue #722). + try { + final trusted = await AppConfig.getTrustedInsecureHosts(); + final client = ApiClient( + baseUrl: serverUrl!, + trustedInsecureHosts: trusted, + ); + await client.setAuthToken(token!); + unawaited(NotificationService.instance.registerDeviceToken(client)); + } catch (_) { + // Non-fatal — idle wake-up still works via server poll / FGS. + } } final elapsed = DateTime.now().difference(start).inMilliseconds; const minDisplayMs = 800; diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index d93c5d9ea..6f4cc641e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -31,7 +31,11 @@ dependencies: # Notifications flutter_local_notifications: ^17.2.1 permission_handler: ^11.1.0 - + # Optional FCM for idle "Still working?" wake-up (Issue #722). Requires + # google-services.json / GoogleService-Info.plist when building with Firebase. + firebase_core: ^3.8.1 + firebase_messaging: ^15.1.6 + # UI Components intl: ^0.18.1 table_calendar: ^3.0.9 diff --git a/requirements.txt b/requirements.txt index d64c85fe5..7764333be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -49,6 +49,9 @@ pikepdf>=8.0.0 # Background tasks APScheduler==3.10.4 +# Optional: Firebase Cloud Messaging for mobile idle wake-up (Issue #722) +firebase-admin==6.5.0 + # Internationalization Flask-Babel==4.0.0 Babel==2.14.0 diff --git a/tests/test_api_v1.py b/tests/test_api_v1.py index b05462680..e25974f7b 100644 --- a/tests/test_api_v1.py +++ b/tests/test_api_v1.py @@ -596,6 +596,39 @@ def test_check_idle_timers_notifies_then_stops(self, client, api_token, test_use # At least ~90 min recorded (start was 2h ago, stop at hb+30m) assert (stopped.duration_seconds or 0) >= 85 * 60 + def test_check_idle_timers_skips_paused(self, client, api_token, test_user, test_project, app): + """Paused timers must not be idle-notified or auto-stopped.""" + 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 + db.session.commit() + + start = local_now() - timedelta(hours=2) + 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=1) + timer.paused_at = local_now() - timedelta(minutes=10) + db.session.add(timer) + db.session.commit() + timer_id = timer.id + + check_idle_timers() + refreshed = db.session.get(TimeEntry, timer_id) + assert refreshed.end_time is None + assert refreshed.idle_notified_at is None + assert refreshed.paused_at is not None + class TestTasks: """Test task endpoints""" From c826d258faffbf01e67a6bb388f45f1f160e8b59 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 2 Sep 2026 20:18:58 +0200 Subject: [PATCH 4/5] chore(release): bump version to 5.13.3 and update docs Document tracked hours, idle timeout hardening, PWA idle 503s, and datetime format fixes; sync desktop, mobile, and extension versions. --- CHANGELOG.md | 15 +++++++++++++++ README.md | 4 ++++ browser-extension/manifest.json | 2 +- browser-extension/package.json | 2 +- desktop/package-lock.json | 4 ++-- desktop/package.json | 2 +- docs/mobile-desktop-apps/DESKTOP_WEBAPP_GAP.md | 6 +++--- mobile/pubspec.yaml | 2 +- setup.py | 2 +- 9 files changed, 29 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30115d0e8..6b2768399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.13.3] - 2026-09-02 + +### Added + +- **Tracked hours on tasks (#745)** — Task list replaces the redundant Actions column with Tracked hours; task detail surfaces totals; CSV export includes Tracked Hours via a bulk aggregate. + +### Changed + +- **Client versions** — Synced Electron (`desktop/package.json`), Flutter (`mobile/pubspec.yaml`), and Chromium extension (`browser-extension/manifest.json` / `package.json`) to **5.13.3** with the webapp (`setup.py`). + ### Fixed +- **Idle timeout / Still working? across clients (#722)** — Extension heartbeats no longer defeat the server safety net when `chrome.idle` is unreliable; backgrounded mobile gets FCM wake-up; the idle scheduler is hardened so forgotten timers cannot run for hours. - **Spurious "Service temporarily unavailable" after tab idle (#746)** — After leaving the dashboard open in a background tab, stale keep-alive sockets caused the first resume requests to fail; the service worker was turning those into fake `503 Offline` responses (breaking JSON parsing and showing stuck error toasts with invisible Retry/Refresh buttons in light mode). The worker no longer intercepts `/api/*`, remaining background polls (chat widget, activity feed, kanban) are quiet + hidden-gated, error toasts dedupe and use theme-aware button styles, and a later resume health sweep clears leftover connectivity toasts. - **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. +### Documentation + +- **Version** — Bumped `setup.py` to **5.13.3** (single source of truth for the application version). + ## [5.13.2] - 2026-08-27 ### Added diff --git a/README.md b/README.md index d5d63187b..1c4004b33 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,10 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh **Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for versioned release history. +### ✨ Highlights of v5.13.3 + +**Patch (5.13.3):** **Tracked hours on tasks (#745)** — list, detail, and CSV export show aggregated tracked time. **Idle timeout (#722)** — extension heartbeats and mobile FCM wake-up keep Still working? reliable so forgotten timers stop. **PWA idle 503s (#746)** — service worker no longer fakes Offline errors after a background tab resumes. **Datetime format** — workday and CRM datetime fields honor the chosen time format via Flatpickr. See [CHANGELOG.md](CHANGELOG.md#5133---2026-09-02). + ### ✨ Highlights of v5.13.2 **Patch (5.13.2):** **Linear setup wizard** — guided API-key setup matching other integrations. **Web manual entry (#728)** — cascading client → project → task pickers with inline create and dark-mode comboboxes. **Mobile start timer** — unified searchable pickers, project-first with client auto-fill, crash/selection/flicker fixes. **Android signing** — stable CI keystore for release APKs. See [CHANGELOG.md](CHANGELOG.md#5132---2026-08-27). diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json index 34f60ffe0..436bb9e4b 100644 --- a/browser-extension/manifest.json +++ b/browser-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "TimeTracker Timer", - "version": "5.13.2", + "version": "5.13.3", "description": "Start and stop TimeTracker timers from your browser toolbar.", "permissions": ["storage", "alarms", "idle", "notifications"], "optional_host_permissions": ["http://*/*", "https://*/*"], diff --git a/browser-extension/package.json b/browser-extension/package.json index 6acb32131..486a2d9dd 100644 --- a/browser-extension/package.json +++ b/browser-extension/package.json @@ -1,7 +1,7 @@ { "name": "timetracker-browser-extension", "private": true, - "version": "5.13.2", + "version": "5.13.3", "description": "TimeTracker Chromium extension (Manifest V3)", "type": "module", "scripts": { diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 1d0235234..cfceeba68 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "timetracker-desktop", - "version": "5.12.0", + "version": "5.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "timetracker-desktop", - "version": "5.12.0", + "version": "5.13.3", "license": "MIT", "dependencies": { "axios": "^1.18.0", diff --git a/desktop/package.json b/desktop/package.json index e837c5f59..befd94cdc 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "timetracker-desktop", - "version": "5.13.2", + "version": "5.13.3", "description": "TimeTracker desktop app for Windows, Linux, and macOS", "main": "src/main/main.js", "scripts": { diff --git a/docs/mobile-desktop-apps/DESKTOP_WEBAPP_GAP.md b/docs/mobile-desktop-apps/DESKTOP_WEBAPP_GAP.md index 37bf02ced..bb32e41d1 100644 --- a/docs/mobile-desktop-apps/DESKTOP_WEBAPP_GAP.md +++ b/docs/mobile-desktop-apps/DESKTOP_WEBAPP_GAP.md @@ -6,9 +6,9 @@ Living checklist for bringing the Electron desktop client up to speed with the F | Component | Version | Source | |-----------|---------|--------| -| Web / backend | 5.12.0 | `setup.py` | -| Desktop | 5.12.0 | `desktop/package.json` | -| Mobile | 5.12.0 | `mobile/pubspec.yaml` | +| Web / backend | 5.13.3 | `setup.py` | +| Desktop | 5.13.3 | `desktop/package.json` | +| Mobile | 5.13.3 | `mobile/pubspec.yaml` | ## What desktop has diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6f4cc641e..316b6bfbc 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: timetracker_mobile description: TimeTracker mobile app for Android and iOS publish_to: 'none' -version: 5.13.2+1 +version: 5.13.3+1 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/setup.py b/setup.py index c7936d6d2..fc2e71458 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='timetracker', - version='5.13.2', + version='5.13.3', packages=find_packages(), include_package_data=True, package_data={ From 08b8e7d15c4a4fae30ff5443d6026e0512b53cb2 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 4 Sep 2026 07:50:23 +0200 Subject: [PATCH 5/5] docs(release): fold post-merge fixes into 5.13.3 notes Restore the 5.13.3 section lost in the main merge and document idle needs-review, graceful offline (#746), and the onboarding tour fix. --- CHANGELOG.md | 15 +++++++++++++-- README.md | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index badd71606..ec2a4ce9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.13.3] - 2026-09-02 + ### Added +- **Tracked hours on tasks (#745)** — Task list replaces the redundant Actions column with Tracked hours; task detail surfaces totals; CSV export includes Tracked Hours via a bulk aggregate. - **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`. +### Changed + +- **Client versions** — Synced Electron (`desktop/package.json`), Flutter (`mobile/pubspec.yaml`), and Chromium extension (`browser-extension/manifest.json` / `package.json`) to **5.13.3** with the webapp (`setup.py`). + ### 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. +- **Idle timeout / Still working? across clients (#722)** — Extension heartbeats no longer defeat the server safety net when `chrome.idle` is unreliable; backgrounded mobile gets FCM wake-up; the idle scheduler is hardened so forgotten timers cannot run for hours. +- **"Service temporarily unavailable" after leaving the dashboard open (#746)** — When the backend went down (or a background tab resumed with stale sockets) while a page stayed open, the service worker answered `/api/*` with a plain-text `Offline` body, so `.json()` parsing threw `SyntaxError`; the chat widget re-polled every 30 seconds forever; and identical error toasts stacked up and evicted the ones carrying Retry/Refresh. Synthetic offline responses are now JSON, remaining background polls (chat, activity feed, kanban) back off after failure and pause in hidden tabs, error toasts dedupe with theme-aware actions, a resume health sweep clears leftover connectivity toasts, and the offline indicator bar is removed as soon as connectivity is restored. - **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. +### Documentation + +- **Version** — Bumped `setup.py` to **5.13.3** (single source of truth for the application version). + ## [5.13.2] - 2026-08-27 ### Added diff --git a/README.md b/README.md index 1c4004b33..e71e153cd 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh ### ✨ Highlights of v5.13.3 -**Patch (5.13.3):** **Tracked hours on tasks (#745)** — list, detail, and CSV export show aggregated tracked time. **Idle timeout (#722)** — extension heartbeats and mobile FCM wake-up keep Still working? reliable so forgotten timers stop. **PWA idle 503s (#746)** — service worker no longer fakes Offline errors after a background tab resumes. **Datetime format** — workday and CRM datetime fields honor the chosen time format via Flatpickr. See [CHANGELOG.md](CHANGELOG.md#5133---2026-09-02). +**Patch (5.13.3):** **Tracked hours on tasks (#745)** — list, detail, and CSV export show aggregated tracked time. **Idle needs-review** — unanswered idle timers are flagged for review (banner, push, API) instead of lost. **Idle timeout (#722)** — extension heartbeats and mobile FCM wake-up keep Still working? reliable. **Graceful offline (#746)** — JSON offline API responses, toast dedupe, and chat backoff when the backend drops. **Onboarding tour** — skip confirmation stays dismissible. **Datetime format** — workday and CRM fields honor the chosen time format via Flatpickr. See [CHANGELOG.md](CHANGELOG.md#5133---2026-09-02). ### ✨ Highlights of v5.13.2