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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Idle needs-review flow** — When a running timer's idle grace expires while no TimeTracker window can answer (tab closed, machine asleep), the time entry is now flagged **needs review** instead of silently lost. A review banner in the web app, a push notification via the service worker, and scheduled-task detection surface these entries so they can be confirmed or corrected; the behavior is configurable in Admin → Settings. The desktop app and browser extension report the same situation, and the v1 API exposes flag/resolve endpoints. Includes migration `183_add_idle_needs_review`.

### Fixed

- **"Service temporarily unavailable" after leaving the dashboard open (#746)** — When the backend went down while a page stayed open, the service worker answered `/api/*` requests with a plain-text `Offline` body, so `.json()` parsing threw `SyntaxError` on top of the connection failure; the chat widget re-polled every 30 seconds forever, and identical error toasts stacked up and evicted the ones carrying the Retry/Refresh buttons. Synthetic offline responses are now JSON, chat-widget polling backs off (2 minutes) after a failure and pauses in hidden tabs, repeated error toasts are deduplicated so the Retry/Refresh actions stay reachable, and the offline indicator bar is removed as soon as connectivity is restored instead of lingering until a reload.
- **Onboarding tour could not be dismissed** — The tour could auto-start twice, stacking a second overlay above its own skip-confirmation dialog, and `!important` z-indexes plus step-transition timers re-raised the tooltip over the "Are you sure you want to skip the tour?" prompt. A double-init guard, non-forced layering, and a pending-skip guard keep the confirmation readable and the tour dismissible.

- **Datetime fields ignore the chosen time format** — Native `datetime-local` inputs (the "Forgot to end your workday?" workday modals, workday history corrections, contact/deal/lead activity forms) render in the browser locale — e.g. 12h AM/PM on en-US — no matter what time format the user or system settings chose. They are now initialized as Flatpickr datetime pickers that display in the user's preferred date + time format (24h by default) while still submitting the same wire format; min/max bounds move to the picker. A regression test enforces that every `datetime-local` input stays prefs-aware. The recurring-tasks "last run" cell and the admin version-update published timestamp also now honor the preference instead of the browser locale.

## [5.13.2] - 2026-08-27
Expand Down
6 changes: 6 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ class Config:
SINGLE_ACTIVE_TIMER = os.getenv("SINGLE_ACTIVE_TIMER", "true").lower() == "true"
IDLE_TIMEOUT_MINUTES = int(os.getenv("IDLE_TIMEOUT_MINUTES", 30))

# Web Push (VAPID) — required for browser push notifications ("Still working?"
# idle alerts with the tab closed, smart reminders). Generate e.g. with py_vapid.
VAPID_PUBLIC_KEY = os.getenv("VAPID_PUBLIC_KEY", "")
VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY", "")
VAPID_CONTACT_EMAIL = os.getenv("VAPID_CONTACT_EMAIL", "")

# User management (default false for production-safe deployments)
ALLOW_SELF_REGISTER = os.getenv("ALLOW_SELF_REGISTER", "false").lower() == "true"
ADMIN_USERNAMES = [u.strip() for u in os.getenv("ADMIN_USERNAMES", "admin").split(",") if u.strip()]
Expand Down
6 changes: 6 additions & 0 deletions app/models/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ class Settings(db.Model):
single_active_timer = db.Column(db.Boolean, default=True, nullable=False)
allow_self_register = db.Column(db.Boolean, default=True, nullable=False)
idle_timeout_minutes = db.Column(db.Integer, default=30, nullable=False)
# Safety cap: auto-stop a running timer flagged for review after N hours
# unanswered (credited back to last activity). 0 disables the cap.
idle_auto_stop_hours = db.Column(db.Integer, default=0, nullable=False)
backup_retention_days = db.Column(db.Integer, default=30, nullable=False)
backup_time = db.Column(db.String(5), default="02:00", nullable=False) # HH:MM format
export_delimiter = db.Column(db.String(1), default=",", nullable=False)
Expand Down Expand Up @@ -275,6 +278,7 @@ def __init__(self, **kwargs):
self.single_active_timer = kwargs.get("single_active_timer", Config.SINGLE_ACTIVE_TIMER)
self.allow_self_register = kwargs.get("allow_self_register", Config.ALLOW_SELF_REGISTER)
self.idle_timeout_minutes = kwargs.get("idle_timeout_minutes", Config.IDLE_TIMEOUT_MINUTES)
self.idle_auto_stop_hours = kwargs.get("idle_auto_stop_hours", 0)
self.backup_retention_days = kwargs.get("backup_retention_days", Config.BACKUP_RETENTION_DAYS)
self.backup_time = kwargs.get("backup_time", Config.BACKUP_TIME)
self.export_delimiter = kwargs.get("export_delimiter", ",")
Expand Down Expand Up @@ -587,6 +591,7 @@ def to_dict(self):
"single_active_timer": self.single_active_timer,
"allow_self_register": self.allow_self_register,
"idle_timeout_minutes": self.idle_timeout_minutes,
"idle_auto_stop_hours": getattr(self, "idle_auto_stop_hours", 0),
"backup_retention_days": self.backup_retention_days,
"backup_time": self.backup_time,
"export_delimiter": self.export_delimiter,
Expand Down Expand Up @@ -924,6 +929,7 @@ def _initialize_from_env(cls, settings_instance):
"SINGLE_ACTIVE_TIMER": "single_active_timer",
"ALLOW_SELF_REGISTER": "allow_self_register",
"IDLE_TIMEOUT_MINUTES": "idle_timeout_minutes",
"IDLE_AUTO_STOP_HOURS": "idle_auto_stop_hours",
"BACKUP_RETENTION_DAYS": "backup_retention_days",
"BACKUP_TIME": "backup_time",
"DEFAULT_DAILY_WORKING_HOURS": "default_daily_working_hours",
Expand Down
13 changes: 13 additions & 0 deletions app/models/time_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ class TimeEntry(db.Model):
# Idle timeout: clients POST /timer/heartbeat while active; server job auto-stops when stale
last_heartbeat_at = db.Column(db.DateTime, nullable=True, index=True)
idle_notified_at = db.Column(db.DateTime, nullable=True)
# Set when the idle grace window expired unanswered: the timer keeps running
# but is flagged so the user can trim/adjust it later instead of losing time.
idle_flagged_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False)

Expand Down Expand Up @@ -284,6 +287,13 @@ def record_heartbeat(self, at=None):
now = at if at is not None else local_now()
self.last_heartbeat_at = now
self.idle_notified_at = None
self.idle_flagged_at = None
self.updated_at = local_now()

def clear_idle_flags(self):
"""Clear pending idle notification and needs-review flag (user confirmed activity or resolved review)."""
self.idle_notified_at = None
self.idle_flagged_at = None
self.updated_at = local_now()

def stop_timer(self, end_time=None):
Expand All @@ -298,6 +308,7 @@ def stop_timer(self, end_time=None):
self.end_time = local_now()

self.idle_notified_at = None
self.idle_flagged_at = None
self.calculate_duration()
self.updated_at = local_now()

Expand Down Expand Up @@ -383,6 +394,8 @@ def to_dict(self):
"last_heartbeat_at": self.last_heartbeat_at.isoformat() if self.last_heartbeat_at else None,
"idle_notified": bool(self.idle_notified_at),
"idle_notified_at": self.idle_notified_at.isoformat() if self.idle_notified_at else None,
"needs_review": bool(self.idle_flagged_at),
"idle_flagged_at": self.idle_flagged_at.isoformat() if self.idle_flagged_at else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"user": self.user.username if self.user else None,
Expand Down
6 changes: 6 additions & 0 deletions app/routes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1415,6 +1415,12 @@ def settings():
settings_obj.single_active_timer = request.form.get("single_active_timer") == "on"
settings_obj.allow_self_register = request.form.get("allow_self_register") == "on"
settings_obj.idle_timeout_minutes = int(request.form.get("idle_timeout_minutes", 30))
try:
settings_obj.idle_auto_stop_hours = max(
0, min(168, int(request.form.get("idle_auto_stop_hours", 0) or 0))
)
except (TypeError, ValueError):
settings_obj.idle_auto_stop_hours = getattr(settings_obj, "idle_auto_stop_hours", 0) or 0
settings_obj.backup_retention_days = int(request.form.get("backup_retention_days", 30))
settings_obj.backup_time = request.form.get("backup_time", "02:00")
settings_obj.export_delimiter = request.form.get("export_delimiter", ",")
Expand Down
66 changes: 66 additions & 0 deletions app/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ def timer_status():
"current_duration": active_timer.current_duration_seconds,
"duration_formatted": active_timer.duration_formatted,
"idle_notified": bool(active_timer.idle_notified_at),
"needs_review": bool(active_timer.idle_flagged_at),
"last_heartbeat_at": (
active_timer.last_heartbeat_at.isoformat() if active_timer.last_heartbeat_at else None
),
Expand Down Expand Up @@ -396,6 +397,71 @@ def api_timer_heartbeat():
return ("", 204)


@api_bp.route("/api/timer/review", methods=["POST"])
@login_required
@deprecated_session_api("/api/v1/timer/review")
def api_timer_review():
"""Resolve an idle needs-review flag on the active timer.

Body: {"action": "trim" | "keep" | "continue", "end_time": optional ISO (for "keep")}
- trim: stop credited to last activity + idle timeout (the old auto-stop behaviour)
- keep: stop at now (or the provided end_time)
- continue: clear the flag and keep the timer running (user is actually working)
"""
from app.models import Settings
from app.models.time_entry import local_now
from app.utils.db import safe_commit

data = request.get_json(silent=True) or {}
action = (data.get("action") or "").strip().lower()
if action not in ("trim", "keep", "continue"):
return jsonify({"error": "action must be trim, keep or continue"}), 400

active_timer = current_user.active_timer
if not active_timer:
return jsonify({"error": "No active timer"}), 400

try:
if action == "continue":
active_timer.clear_idle_flags()
active_timer.last_heartbeat_at = local_now()
if not safe_commit("timer_review", {"user_id": current_user.id, "entry_id": active_timer.id}):
db.session.rollback()
return jsonify({"error": "Failed to resolve review"}), 500
return jsonify({"ok": True, "stopped": False})

if action == "keep" and data.get("end_time"):
try:
end_time = datetime.fromisoformat(data["end_time"])
except ValueError:
return jsonify({"error": "invalid end_time"}), 400
if end_time <= active_timer.start_time:
return jsonify({"error": "end_time must be after start_time"}), 400
active_timer.stop_timer(end_time=end_time)
elif action == "trim":
settings = Settings.get_settings()
idle_minutes = max(1, min(480, int(getattr(settings, "idle_timeout_minutes", 30) or 30)))
last_active = active_timer.last_heartbeat_at or active_timer.start_time
if getattr(last_active, "tzinfo", None) is not None:
last_active = last_active.replace(tzinfo=None)
stop_at = last_active + timedelta(minutes=idle_minutes)
now = local_now()
if stop_at > now:
stop_at = now
active_timer.stop_timer(end_time=stop_at)
else: # keep at now
active_timer.stop_timer()

return jsonify({"ok": True, "stopped": True, "time_entry": active_timer.to_dict()})
except ValueError as e:
db.session.rollback()
return jsonify({"error": str(e)}), 400
except Exception as e:
db.session.rollback()
current_app.logger.warning("Timer review failed: %s", e)
return jsonify({"error": "Failed to resolve review"}), 500


@api_bp.route("/api/tags")
@login_required
def get_recent_tags():
Expand Down
71 changes: 70 additions & 1 deletion app/routes/api_v1_time_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Sub-blueprint for /api/v1/time-entries and /api/v1/timer/*.
"""

from flask import Blueprint, g, jsonify, request
from flask import Blueprint, current_app, g, jsonify, request
from marshmallow import ValidationError

from app.routes.api_v1_common import _parse_date_range, paginate_query
Expand Down Expand Up @@ -390,6 +390,7 @@ def timer_status():
"timer": active_timer.to_dict(),
"idle_timeout_minutes": idle_timeout_minutes,
"idle_notified": bool(active_timer.idle_notified_at),
"needs_review": bool(active_timer.idle_flagged_at),
}
)

Expand Down Expand Up @@ -428,6 +429,74 @@ def timer_heartbeat():
return ("", 204)


@api_v1_time_entries_bp.route("/timer/review", methods=["POST"])
@require_api_token("write:time_entries")
def timer_review():
"""Resolve an idle needs-review flag on the active timer.

Body: {"action": "trim" | "keep" | "continue", "end_time": optional ISO (for "keep")}
- trim: stop credited to last activity + idle timeout (the old auto-stop behaviour)
- keep: stop at now (or the provided end_time)
- continue: clear the flag and keep the timer running (user is actually working)
"""
from datetime import datetime, timedelta

from app.models import Settings
from app.models.time_entry import local_now
from app.utils.db import safe_commit

data = request.get_json(silent=True) or {}
action = (data.get("action") or "").strip().lower()
if action not in ("trim", "keep", "continue"):
return error_response("action must be trim, keep or continue", error_code="invalid_action", status_code=400)

active_timer = g.api_user.active_timer
if not active_timer:
return error_response("No active timer", error_code="no_active_timer", status_code=400)

try:
if action == "continue":
active_timer.clear_idle_flags()
active_timer.last_heartbeat_at = local_now()
if not safe_commit("timer_review", {"user_id": g.api_user.id, "entry_id": active_timer.id}):
db.session.rollback()
return error_response("Failed to resolve review", error_code="database_error", status_code=500)
return jsonify({"ok": True, "stopped": False})

if action == "keep" and data.get("end_time"):
try:
end_time = datetime.fromisoformat(data["end_time"])
except ValueError:
return error_response("invalid end_time", error_code="invalid_end_time", status_code=400)
if end_time <= active_timer.start_time:
return error_response(
"end_time must be after start_time", error_code="invalid_end_time", status_code=400
)
active_timer.stop_timer(end_time=end_time)
elif action == "trim":
settings = Settings.get_settings()
idle_minutes = max(1, min(480, int(getattr(settings, "idle_timeout_minutes", 30) or 30)))
last_active = active_timer.last_heartbeat_at or active_timer.start_time
if getattr(last_active, "tzinfo", None) is not None:
last_active = last_active.replace(tzinfo=None)
stop_at = last_active + timedelta(minutes=idle_minutes)
now = local_now()
if stop_at > now:
stop_at = now
active_timer.stop_timer(end_time=stop_at)
else: # keep at now
active_timer.stop_timer()

return jsonify({"ok": True, "stopped": True, "time_entry": active_timer.to_dict()})
except ValueError as e:
db.session.rollback()
return error_response(str(e), error_code="review_failed", status_code=400)
except Exception as e:
db.session.rollback()
current_app.logger.warning("Timer review failed for user %s: %s", g.api_user.id, e)
return error_response("Failed to resolve review", error_code="database_error", status_code=500)


@api_v1_time_entries_bp.route("/timer/start", methods=["POST"])
@require_api_token("write:time_entries")
def start_timer():
Expand Down
14 changes: 14 additions & 0 deletions app/static/error-handling-enhanced.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,12 @@ class EnhancedErrorHandler {
}

handleOnline() {
// Remove offline indicator
const indicator = document.getElementById('offline-indicator');
if (indicator) {
indicator.remove();
}

// Show online indicator
this.showOnlineIndicator();

Expand Down Expand Up @@ -470,6 +476,14 @@ class EnhancedErrorHandler {
* Show Error with Retry Button
*/
showErrorWithRetry(message, status, retryCallback) {
// Deduplicate: polling loops can hit the same failure repeatedly (e.g. every
// 30s while the backend is down). Keep the existing toast — it already has
// retry/recovery buttons — instead of stacking identical error cards.
if (this.isDuplicateError(message)) {
console.warn('Duplicate error suppressed:', message);
return null;
}

const recoveryOptions = this.getRecoveryOptions(status);

// Create error toast with retry
Expand Down
Loading
Loading