feat(dashboard): add pause, resume, and time adjustment to timer widget

- Add Pause and Stop buttons when a timer is running; Pause saves the
  segment so users can resume later without losing context.
- When no timer is active, show prominent 'Resume (project name)' to
  restart with the same project/task/notes as the last entry.
- Add quick time adjustment (-15 / -5 / +5 / +15 min) for the active
  timer via POST /timer/adjust (delta_minutes); limits ±4 hours.
- Update CHANGELOG, in-app Help, GETTING_STARTED, and FEATURES_COMPLETE
  to document the new dashboard timer behavior.
This commit is contained in:
Dries Peeters
2026-02-17 20:26:14 +01:00
parent 3f56a06ef0
commit b6d208090b
6 changed files with 101 additions and 8 deletions
+3
View File
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Dashboard timer widget** — Pause and Stop buttons while a timer is running (Pause saves the segment so you can resume later). When no timer is active, a prominent "Resume (project name)" button restarts tracking with the same project/task/notes as your last entry. Quick time adjustment buttons (15 / 5 / +5 / +15 minutes) let you correct the current session without leaving the dashboard. New route `POST /timer/adjust` for start-time adjustment.
### Changed
- **Log Time Manually page** — Redesigned for a more professional layout: form grouped into sections (Project & task, Date & time, Details) with clear headings and icons; main card uses rounded-xl and shadow-lg; unified label and helper text styling; primary "Log Time" and secondary "Clear" buttons aligned with dashboard button styles; duplicate-entry banner uses rounded-xl.
+43
View File
@@ -538,6 +538,49 @@ def stop_timer():
return redirect(url_for("main.dashboard"))
@timer_bp.route("/timer/adjust", methods=["POST"])
@login_required
def adjust_timer():
"""Adjust the active timer's start time by delta_minutes (positive = add time, negative = subtract)."""
active_timer = current_user.active_timer
if not active_timer:
flash(_("No active timer to adjust"), "error")
return redirect(url_for("main.dashboard"))
try:
delta_minutes = int(request.form.get("delta_minutes", 0))
except (TypeError, ValueError):
flash(_("Invalid adjustment value"), "error")
return redirect(url_for("main.dashboard"))
if delta_minutes == 0:
return redirect(url_for("main.dashboard"))
# Clamp to avoid extreme shifts (e.g. ±4 hours)
delta_minutes = max(-240, min(240, delta_minutes))
from app.models.time_entry import local_now
new_start = active_timer.start_time - timedelta(minutes=delta_minutes)
# Do not set start_time in the future
now_local = local_now()
if new_start > now_local:
new_start = now_local
active_timer.start_time = new_start
active_timer.updated_at = now_local
db.session.commit()
try:
from app.utils.cache import get_cache
cache = get_cache()
cache.delete(f"dashboard:{current_user.id}")
except Exception:
pass
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify({"success": True, "start_time": active_timer.start_time.isoformat()})
return redirect(url_for("main.dashboard"))
@timer_bp.route("/timer/status")
@login_required
def timer_status():
+50 -7
View File
@@ -128,18 +128,61 @@
<p class="text-sm font-semibold text-text-light dark:text-text-dark mt-2">
{{ _('Elapsed') }}: <span id="dashboard-timer-elapsed">{{ active_timer.duration_formatted }}</span>
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Adjust time') }}:</span>
<form action="{{ url_for('timer.adjust_timer') }}" method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="delta_minutes" value="-15">
<button type="submit" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-text-light dark:text-text-dark text-sm font-medium transition-colors" title="{{ _('Subtract 15 min') }}">15</button>
</form>
<form action="{{ url_for('timer.adjust_timer') }}" method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="delta_minutes" value="-5">
<button type="submit" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-text-light dark:text-text-dark text-sm font-medium transition-colors" title="{{ _('Subtract 5 min') }}">5</button>
</form>
<form action="{{ url_for('timer.adjust_timer') }}" method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="delta_minutes" value="5">
<button type="submit" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-text-light dark:text-text-dark text-sm font-medium transition-colors" title="{{ _('Add 5 min') }}">+5</button>
</form>
<form action="{{ url_for('timer.adjust_timer') }}" method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="delta_minutes" value="15">
<button type="submit" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-text-light dark:text-text-dark text-sm font-medium transition-colors" title="{{ _('Add 15 min') }}">+15</button>
</form>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<form action="{{ url_for('timer.stop_timer') }}" method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bg-amber-500 hover:bg-amber-600 text-white px-5 py-2.5 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5" title="{{ _('Pause and save current time; you can resume later') }}">
<i class="fas fa-pause mr-2"></i>{{ _('Pause') }}
</button>
</form>
<form action="{{ url_for('timer.stop_timer') }}" method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bg-red-500 hover:bg-red-600 text-white px-5 py-2.5 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5">
<i class="fas fa-stop mr-2"></i>{{ _('Stop') }}
</button>
</form>
</div>
<form action="{{ url_for('timer.stop_timer') }}" method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bg-red-500 hover:bg-red-600 text-white px-5 py-2.5 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5">
<i class="fas fa-stop mr-2"></i>{{ _('Stop Timer') }}
</button>
</form>
</div>
{% else %}
<div class="text-center py-4">
<p class="text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('No active timer.') }}</p>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Click "Start Timer" to begin tracking your time.') }}</p>
{% if recent_entries %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Resume your last session or start a new timer.') }}</p>
<div class="flex flex-wrap justify-center gap-3">
<a href="{{ url_for('timer.resume_timer', timer_id=recent_entries[0].id) }}" class="inline-flex items-center bg-green-500 hover:bg-green-600 text-white px-5 py-2.5 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5">
<i class="fas fa-play mr-2"></i>{{ _('Resume') }}{% if recent_entries[0].project %} ({{ recent_entries[0].project.name }}){% elif recent_entries[0].client %} ({{ recent_entries[0].client.name }}){% endif %}
</a>
<button type="button" id="openStartTimer" class="bg-primary hover:bg-primary-dark text-white px-5 py-2.5 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5">
<i class="fas fa-plus mr-2"></i>{{ _('Start new') }}
</button>
</div>
{% else %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Click "Start Timer" above to begin tracking your time.') }}</p>
{% endif %}
</div>
{% endif %}
</div>
+3
View File
@@ -133,6 +133,9 @@
<h4 class="font-semibold mb-2">{{ _('Timer Features') }}</h4>
<ul class="space-y-1 text-text-muted-light dark:text-text-muted-dark">
<li><i class="fas fa-check text-green-600 mr-2"></i>{{ _('Real-time duration display') }}</li>
<li><i class="fas fa-check text-green-600 mr-2"></i>{{ _('Pause and Stop on the dashboard — Pause saves your time so you can resume later') }}</li>
<li><i class="fas fa-check text-green-600 mr-2"></i>{{ _('Resume last session with one click (same project/task/notes)') }}</li>
<li><i class="fas fa-check text-green-600 mr-2"></i>{{ _('Quick time adjustment (15 / 5 / +5 / +15 min) while the timer is running') }}</li>
<li><i class="fas fa-check text-green-600 mr-2"></i>{{ _('Continues running if browser closes') }}</li>
<li><i class="fas fa-check text-green-600 mr-2"></i>{{ _('Automatic idle detection (configurable)') }}</li>
<li><i class="fas fa-check text-green-600 mr-2"></i>{{ _('One active timer per user (configurable)') }}</li>
+1
View File
@@ -57,6 +57,7 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management
#### 4. **Timer Management**
- Start, stop, pause, and resume timers
- **Dashboard timer widget**: Pause (saves segment) and Stop; one-click "Resume (project)" to continue with the same project/task/notes; quick time adjustment (15 / 5 / +5 / +15 min) while running
- Edit active timers
- Delete timers
- Timer history and audit trail
+1 -1
View File
@@ -249,7 +249,7 @@ Break your project into manageable tasks:
2. **Select a project** (and optionally a task)
3. **Click Start** — the timer begins
4. **Work on your task** — timer continues even if you close the browser
5. **Click Stop** when finished — time entry is saved automatically
5. Use **Pause** to save the segment and resume later, or **Stop** when finished. Use the **15 / 5 / +5 / +15** buttons to adjust the current session time if needed.
**💡 Tip**: The timer runs on the server, so it keeps going even if you:
- Close your browser