Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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

**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).
Expand Down
8 changes: 8 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 58 additions & 4 deletions app/models/push_subscription.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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://<device_token>`` 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)
Expand All @@ -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"<PushSubscription {self.id} for user {self.user_id}>"
Expand All @@ -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,
Expand All @@ -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
68 changes: 60 additions & 8 deletions app/routes/push_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
16 changes: 16 additions & 0 deletions app/routes/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -1486,6 +1500,7 @@ def parse_ids(param_name):
"Created By",
"Due Date",
"Estimated Hours",
"Tracked Hours",
"Created At",
"Updated At",
]
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/services/time_tracking_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}):
Expand Down
6 changes: 4 additions & 2 deletions app/templates/components/activity_feed_widget.html
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,8 @@ <h2 class="text-lg font-semibold">
credentials: 'same-origin',
headers: {
'Accept': 'application/json'
}
},
__ttQuiet: true
});
if (!response.ok) {
console.error('API response not OK:', response.status, response.statusText);
Expand Down Expand Up @@ -421,8 +422,9 @@ <h2 class="text-lg font-semibold">
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();
}
Expand Down
6 changes: 4 additions & 2 deletions app/templates/tasks/_kanban.html
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,8 @@ <h6 class="kanban-card-title">
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();
Expand Down Expand Up @@ -1034,9 +1035,10 @@ <h6 class="kanban-column-title mb-0">${col.label}</h6>

// 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);
Expand Down
12 changes: 8 additions & 4 deletions app/templates/tasks/_tasks_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ <h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
<th class="px-4 py-3" data-sortable>Status</th>
<th class="px-4 py-3 table-number" data-sortable>Due</th>
<th class="px-4 py-3 table-number" data-sortable>{{ _('Estimated') }}</th>
<th class="px-4 py-3 table-number" data-sortable>{{ _('Tracked') }}</th>
<th class="px-4 py-3 table-number" data-sortable>Progress</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody>
Expand Down Expand Up @@ -90,15 +90,19 @@ <h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
{% endif %}
</td>
<td class="p-4 table-number" data-label="{{ _('Tracked') }}">
{% if task.total_hours %}
{{ task.total_hours }} {{ _('h') }}
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
{% endif %}
</td>
<td class="p-4 table-number" data-label="{{ _('Progress') }}">
{% set pct = task.progress_percentage or 0 %}
<div class="w-28 h-2 bg-gray-200 dark:bg-gray-700 rounded">
<div class="h-2 rounded {{ 'bg-emerald-500' if pct>=100 else 'bg-primary' }}" style="width: {{ [pct,100]|min }}%"></div>
</div>
</td>
<td class="p-4 mobile-actions" data-label="{{ _('Actions') }}">
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-primary hover:underline">View</a>
</td>
</tr>
{% else %}
{% endfor %}
Expand Down
Loading
Loading