Files
TimeTracker/app/templates/main/dashboard.html
T
Dries Peeters 2ee8da33a0 feat: TimeTracker polish and production readiness (plan implementation)
Quick wins (Phase A):
- A1: Quick timer actions — last timer context, Repeat last button, Quick start one-click form; pre-fill modal and tags from last entry
- A2: Unified empty states using empty_state macro on custom_view, time_entry_templates, saved_filters, issues; add loading_placeholder macro
- A3: Dashboard hierarchy — Activity and Support/Donate moved to secondary row below fold with reduced visual weight
- A4: Error/feedback consistency (flash-to-toast already in place)

Medium impact (Phase B):
- B5: Split API v1 — api_v1_common.py (shared helpers), api_v1_time_entries.py sub-blueprint for time-entries and timer/*; register api_v1_time_entries_bp
- B6: Start Timer UX — templates as prominent chips at top of modal; default last context and quick start from A1
- B7: Week in review — ReportingService.get_week_in_review(), route /reports/week-in-review, template and link from reports index
- B8: Tags discoverability — GET /api/tags, recent_tags in dashboard, tags input with datalist in Start Timer modal; last context includes tags
- B9: Frontend consolidation — document onboarding.js vs onboarding-enhanced.js in base.html
- B10: API validation — Marshmallow TimeEntryCreateSchema/TimeEntryUpdateSchema and handle_validation_error in api_v1_time_entries create/update

UX: Remove duplicate Timer actions — single Repeat last and Start Timer in header; body shows only Resume when recent entries exist (no duplicate Repeat last or Start new).
2026-03-11 08:00:47 +01:00

1479 lines
90 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% from "components/cards.html" import info_card, stat_card %}
{% from "components/ui.html" import confirm_dialog, modal %}
{% from "components/client_select.html" import client_select %}
{% block extra_css %}
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css">
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/theme/toastui-editor-dark.css">
{% endblock %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-text-light dark:text-text-dark mb-2">{{ _('Dashboard') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _("Here's a quick overview of your work.") }}</p>
</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark mt-2 md:mt-0 flex items-center gap-2">
<i class="fas fa-tachometer-alt text-primary"></i>
<span>/ {{ _('Dashboard') }}</span>
</div>
</div>
<!-- Key Stats with Sparklines -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
<!-- Today's Hours Card -->
<div class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 border border-blue-200 dark:border-blue-800 p-6 rounded-xl shadow-sm dashboard-widget" id="todayHours">
<div class="flex items-center justify-between mb-4">
<div>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 mb-1">{{ _('Today\'s Hours') }}</p>
<div class="flex items-baseline gap-2">
<span class="text-2xl lg:text-3xl font-bold text-blue-900 dark:text-blue-100 stat-value" id="todayHoursValue">{{ "%.2f"|format(today_hours) }}</span>
<span class="text-sm text-blue-600/70 dark:text-blue-400/70">hours</span>
</div>
{% if standard_hours_per_day is defined and today_overtime_hours is defined %}
<div id="todayOvertimeLine" class="mt-1 text-xs text-blue-700 dark:text-blue-300" {% if today_overtime_hours <= 0 %}style="display: none;"{% endif %}>
{% if today_overtime_hours > 0 %}
<span class="font-medium">+ {{ "%.2f"|format(today_overtime_hours) }}h {{ _('overtime') }}</span>
{% elif standard_hours_per_day %}
<span class="text-blue-600/70 dark:text-blue-400/70">{{ "%.2f"|format(today_hours) }}h / {{ "%.1f"|format(standard_hours_per_day) }}h</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="bg-blue-500/10 dark:bg-blue-400/10 p-4 rounded-full">
<i class="fas fa-clock text-blue-600 dark:text-blue-400 text-2xl"></i>
</div>
</div>
<div class="sparkline-container" data-sparkline='[0,0,0,0,0,0,0]' data-sparkline-id="today" data-color="#3b82f6" id="sparkline-today" aria-label="{{ _('Loading...') }}"></div>
</div>
<!-- Week's Hours Card -->
<div class="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border border-green-200 dark:border-green-800 p-6 rounded-xl shadow-sm dashboard-widget" id="weekHours">
<div class="flex items-center justify-between mb-4">
<div>
<p class="text-sm font-medium text-green-600 dark:text-green-400 mb-1">{{ _('Week\'s Hours') }}</p>
<div class="flex items-baseline gap-2">
<span class="text-2xl lg:text-3xl font-bold text-green-900 dark:text-green-100 stat-value" id="weekHoursValue">{{ "%.2f"|format(week_hours) }}</span>
<span class="text-sm text-green-600/70 dark:text-green-400/70">hours</span>
</div>
{% if standard_hours_per_day is defined and week_overtime_hours is defined %}
<div id="weekOvertimeLine" class="mt-1 text-xs text-green-700 dark:text-green-300" {% if week_overtime_hours <= 0 %}style="display: none;"{% endif %}>
{% if week_overtime_hours > 0 %}<span class="font-medium">+ {{ "%.2f"|format(week_overtime_hours) }}h {{ _('overtime') }}</span>{% endif %}
</div>
{% endif %}
</div>
<div class="bg-green-500/10 dark:bg-green-400/10 p-4 rounded-full">
<i class="fas fa-calendar-week text-green-600 dark:text-green-400 text-2xl"></i>
</div>
</div>
<div class="sparkline-container" data-sparkline='[0,0,0,0,0,0,0]' data-sparkline-id="week" data-color="#10b981" id="sparkline-week" aria-label="{{ _('Loading...') }}"></div>
</div>
<!-- Month's Hours Card -->
<div class="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 border border-purple-200 dark:border-purple-800 p-6 rounded-xl shadow-sm dashboard-widget" id="monthHours">
<div class="flex items-center justify-between mb-4">
<div>
<p class="text-sm font-medium text-purple-600 dark:text-purple-400 mb-1">{{ _('Month\'s Hours') }}</p>
<div class="flex items-baseline gap-2">
<span class="text-2xl lg:text-3xl font-bold text-purple-900 dark:text-purple-100 stat-value" id="monthHoursValue">{{ "%.2f"|format(month_hours) }}</span>
<span class="text-sm text-purple-600/70 dark:text-purple-400/70">hours</span>
</div>
</div>
<div class="bg-purple-500/10 dark:bg-purple-400/10 p-4 rounded-full">
<i class="fas fa-calendar-alt text-purple-600 dark:text-purple-400 text-2xl"></i>
</div>
</div>
<div class="sparkline-container" data-sparkline='[0,0,0,0,0,0,0]' data-sparkline-id="month" data-color="#8b5cf6" id="sparkline-month" aria-label="{{ _('Loading...') }}"></div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<!-- Left Column: Active Timer & Recent Entries -->
<div class="lg:col-span-2 space-y-6">
<!-- Active Timer -->
<div class="{% if active_timer %}bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border border-blue-200 dark:border-blue-800{% else %}bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark{% endif %} p-6 rounded-xl shadow-sm dashboard-widget animated-card" {% if active_timer %}data-timer-start="{{ active_timer.start_time.isoformat() }}" data-today-hours="{{ today_hours }}" data-week-hours="{{ week_hours }}" data-month-hours="{{ month_hours }}"{% endif %}>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="{% if active_timer %}bg-blue-500/10 dark:bg-blue-400/10{% else %}bg-gray-100 dark:bg-gray-800{% endif %} p-3 rounded-lg">
<i class="fas fa-stopwatch {% if active_timer %}text-blue-600 dark:text-blue-400 animate-pulse{% else %}text-gray-600 dark:text-gray-400{% endif %} text-xl"></i>
</div>
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Timer') }}</h2>
</div>
{% if not active_timer %}
<div class="flex flex-wrap items-center gap-2">
{% if last_timer_context %}
<button type="button" class="btn btn-secondary js-repeat-last-timer" title="{{ _('Start timer with same project, task and notes as last entry') }}">
<i class="fas fa-redo"></i>{{ _('Repeat last') }}
</button>
<form method="POST" action="{{ url_for('timer.start_timer') }}" class="inline js-quick-start-last-form" data-confirm-message="{{ _('Start timer with last project and notes?') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="project_id" value="{{ last_timer_context.project_id or '' }}">
<input type="hidden" name="client_id" value="{{ last_timer_context.client_id or '' }}">
<input type="hidden" name="task_id" value="{{ last_timer_context.task_id or '' }}">
<input type="hidden" name="notes" value="{{ last_timer_context.notes or '' }}">
<input type="hidden" name="tags" value="{{ last_timer_context.tags or '' }}">
<button type="submit" class="btn btn-primary btn-sm">
<i class="fas fa-bolt"></i>{{ _('Quick start') }}{% if last_timer_context.project_name %} ({{ last_timer_context.project_name }}){% elif last_timer_context.client_name %} ({{ last_timer_context.client_name }}){% endif %}
</button>
</form>
{% endif %}
<button type="button" class="btn btn-primary js-open-start-timer">
<i class="fas fa-play"></i>{{ _('Start Timer') }}
</button>
</div>
{% endif %}
</div>
{% if active_timer %}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
<span class="w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full mr-2 animate-pulse"></span>
{{ _('Running') }}
</span>
</div>
<p class="font-semibold text-lg text-text-light dark:text-text-dark mb-1">
{% if active_timer.project %}
<i class="fas fa-folder text-blue-600 dark:text-blue-400 mr-2"></i>{{ active_timer.project.name }}
{% elif active_timer.client %}
<i class="fas fa-building text-blue-600 dark:text-blue-400 mr-2"></i>{{ active_timer.client.name }} <span class="text-xs text-gray-500">({{ _('Direct') }})</span>
{% else %}
{{ _('No project') }}
{% endif %}
</p>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-clock mr-1"></i>{{ _('Started at') }} {{ active_timer.start_time|user_time }}
</p>
<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-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" title="{{ _('Stop and save current time') }}">
<i class="fas fa-stop mr-2"></i>{{ _('Stop & save') }}
</button>
</form>
</div>
</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>
{% if recent_entries %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Resume your last session or use the buttons above to repeat last / 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>
</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>
<!-- Recent Entries -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 dashboard-widget animated-card">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="bg-primary/10 dark:bg-primary/20 p-3 rounded-lg">
<i class="fas fa-history text-primary text-xl"></i>
</div>
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Recent Entries') }}</h2>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left responsive-cards">
<thead class="border-b-2 border-border-light dark:border-border-dark">
<tr>
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Source') }}</th>
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Task') }}</th>
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Notes') }}</th>
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Tags') }}</th>
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Duration') }}</th>
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Date') }}</th>
<th class="p-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for entry in recent_entries %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark transition-colors duration-150">
<td class="p-4" data-label="{{ _('Source') }}">
{% if entry.project %}
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}" class="inline-flex items-center gap-2 text-primary hover:text-primary-dark hover:underline font-medium">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
<i class="fas fa-folder mr-1"></i>{{ entry.project.name }}
</span>
</a>
{% elif entry.client %}
<a href="{{ url_for('clients.view_client', client_id=entry.client.id) }}" class="inline-flex items-center gap-2 text-primary hover:text-primary-dark hover:underline font-medium">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
<i class="fas fa-building mr-1"></i>{{ entry.client.name }} <span class="ml-1 text-xs opacity-75">({{ _('Direct') }})</span>
</span>
</a>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('N/A') }}</span>
{% endif %}
</td>
<td class="p-4" data-label="{{ _('Task') }}">
{% if entry.task %}
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{{ entry.task.name }}
</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark">-</span>
{% endif %}
</td>
<td class="p-4" data-label="{{ _('Notes') }}">
{% if entry.notes %}
<span class="text-sm text-text-light dark:text-text-dark" title="{{ entry.notes|striptags }}">{{ entry.notes|striptags|truncate(60) }}</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark">-</span>
{% endif %}
</td>
<td class="p-4" data-label="{{ _('Tags') }}">
{% if entry.tags %}
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{{ entry.tags }}
</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark">-</span>
{% endif %}
</td>
<td class="p-4" data-label="{{ _('Duration') }}">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
<i class="fas fa-clock mr-1"></i>{% if active_timer and entry.id == active_timer.id %}<span class="dashboard-live-duration" data-timer-start="{{ active_timer.start_time.isoformat() }}">{{ entry.duration_formatted }}</span>{% else %}{{ entry.duration_formatted }}{% endif %}
</span>
</td>
<td class="p-4 text-sm text-text-light dark:text-text-dark" data-label="{{ _('Date') }}">{{ entry.start_time|user_datetime }}</td>
<td class="p-4" data-label="{{ _('Actions') }}">
<div class="flex flex-wrap gap-2">
<a href="{{ url_for('timer.resume_timer', timer_id=entry.id) }}" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-green-100 hover:bg-green-200 dark:bg-green-900/30 dark:hover:bg-green-900/50 text-green-600 dark:text-green-400 transition-colors" title="{{ _('Resume - Start a new timer with same properties') }}">
<i class="fas fa-play text-xs"></i>
</a>
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 hover:bg-primary/20 dark:bg-primary/20 dark:hover:bg-primary/30 text-primary dark:text-primary transition-colors" title="{{ _('Edit entry') }}">
<i class="fas fa-edit text-xs"></i>
</a>
<a href="{{ url_for('timer.duplicate_timer', timer_id=entry.id) }}" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 text-blue-600 dark:text-blue-400 transition-colors" title="{{ _('Duplicate entry') }}">
<i class="fas fa-copy text-xs"></i>
</a>
{% if current_user.is_admin or entry.user_id == current_user.id %}
<form id="confirmDeleteEntry-{{ entry.id }}-form" method="POST" action="{{ url_for('timer.delete_timer', timer_id=entry.id) }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
<button type="button" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-red-100 hover:bg-red-200 dark:bg-red-900/30 dark:hover:bg-red-900/50 text-red-600 dark:text-red-400 transition-colors" title="{{ _('Delete entry') }}" onclick="document.getElementById('confirmDeleteEntry-{{ entry.id }}').classList.remove('hidden')">
<i class="fas fa-trash text-xs"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="p-12 text-center">
<div class="flex flex-col items-center justify-center">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-6 w-20 h-20 mx-auto mb-4 flex items-center justify-center">
<i class="fas fa-inbox text-3xl text-gray-400"></i>
</div>
<p class="text-text-muted-light dark:text-text-muted-dark font-medium mb-1">{{ _('No recent entries found.') }}</p>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Start tracking time to see entries here.') }}</p>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Right Column: Real Insights -->
<div class="space-y-6">
<!-- Weekly Goal Widget -->
{% if is_module_enabled('weekly_goals') %}
{% if current_week_goal %}
<div class="bg-gradient-to-br from-blue-500 to-purple-600 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 dashboard-widget animated-card text-white">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="bg-white/20 backdrop-blur-sm p-3 rounded-lg">
<i class="fas fa-bullseye text-xl"></i>
</div>
<h2 class="text-lg font-semibold">{{ _('Weekly Goal') }}</h2>
</div>
<a href="{{ url_for('weekly_goals.index') }}" class="text-white/80 hover:text-white transition-colors">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
<div class="mb-4">
<div class="flex justify-between text-sm mb-3 opacity-90">
<span class="font-medium">{{ current_week_goal.actual_hours }}h / {{ current_week_goal.target_hours }}h</span>
<span class="font-bold">{{ current_week_goal.progress_percentage }}%</span>
</div>
<div class="w-full bg-white/30 backdrop-blur-sm rounded-full h-3 overflow-hidden shadow-inner">
<div class="bg-white rounded-full h-3 transition-all duration-500 shadow-sm"
style="width: {{ current_week_goal.progress_percentage }}%"></div>
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-sm mb-3">
<div class="bg-white/20 backdrop-blur-sm rounded-lg p-3 border border-white/10">
<div class="opacity-90 text-xs mb-1">{{ _('Remaining') }}</div>
<div class="font-bold text-lg">{{ current_week_goal.remaining_hours }}h</div>
</div>
<div class="bg-white/20 backdrop-blur-sm rounded-lg p-3 border border-white/10">
<div class="opacity-90 text-xs mb-1">{{ _('Days Left') }}</div>
<div class="font-bold text-lg">{{ current_week_goal.days_remaining }}</div>
</div>
</div>
{% if current_week_goal.days_remaining > 0 and current_week_goal.remaining_hours > 0 %}
<div class="mt-3 text-sm opacity-90 bg-white/10 backdrop-blur-sm rounded-lg p-2 border border-white/10">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Need') }} {{ current_week_goal.average_hours_per_day }}h/day {{ _('to reach goal') }}
</div>
{% endif %}
</div>
{% else %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 animated-card border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-center py-2">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-6 w-20 h-20 mx-auto mb-4 flex items-center justify-center">
<i class="fas fa-bullseye text-3xl text-gray-400"></i>
</div>
<h3 class="text-base font-semibold text-text-light dark:text-text-dark mb-2">
{{ _('No Weekly Goal') }}
</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('Set a weekly time goal to track your progress') }}
</p>
<a href="{{ url_for('weekly_goals.create') }}"
class="inline-flex items-center justify-center bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg text-sm font-semibold shadow-md hover:shadow-lg transition-all duration-200 transform hover:-translate-y-0.5">
<i class="fas fa-plus mr-2"></i> {{ _('Create Goal') }}
</a>
</div>
</div>
{% endif %}
{% endif %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 dashboard-widget animated-card">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="bg-purple-500/10 dark:bg-purple-400/10 p-3 rounded-lg">
<i class="fas fa-chart-line text-purple-600 dark:text-purple-400 text-xl"></i>
</div>
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Top Projects (30 days)') }}</h2>
</div>
</div>
{% if top_projects %}
{% set max_hours = top_projects[0].hours if top_projects and top_projects[0].hours > 0 else 1 %}
<ul class="space-y-4">
{% for item in top_projects %}
<li class="group">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 flex-1 min-w-0">
<div class="flex-shrink-0 w-8 h-8 rounded-lg bg-gradient-to-br from-purple-500 to-indigo-500 flex items-center justify-center text-white text-xs font-bold">
{{ loop.index }}
</div>
<div class="flex-1 min-w-0">
<div class="font-semibold text-text-light dark:text-text-dark truncate">{{ item.project.name }}</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark flex items-center gap-2 mt-1">
<span class="inline-flex items-center">
<i class="fas fa-dollar-sign text-green-600 dark:text-green-400 mr-1"></i>
{{ _('Billable') }}: {{ '%.1f'|format(item.billable_hours) }}h
</span>
</div>
</div>
</div>
<div class="text-right ml-3">
<div class="font-bold text-lg text-text-light dark:text-text-dark">{{ '%.1f'|format(item.hours) }}h</div>
</div>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div class="bg-gradient-to-r from-purple-500 to-indigo-500 h-2 rounded-full transition-all duration-500" style="width: {{ (item.hours / max_hours * 100)|round }}%"></div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-center py-8">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-6 w-20 h-20 mx-auto mb-4 flex items-center justify-center">
<i class="fas fa-folder-open text-3xl text-gray-400"></i>
</div>
<p class="text-text-muted-light dark:text-text-muted-dark font-medium mb-1">{{ _('No activity in the last 30 days.') }}</p>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Start tracking time on projects to see them here.') }}</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Secondary row: Activity and Support (below main focus) -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
<!-- Activity Timeline Widget - reduced visual weight -->
<div class="lg:col-span-2 bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-border-dark shadow-sm dashboard-widget">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<div class="bg-indigo-500/10 dark:bg-indigo-400/10 p-2 rounded-lg">
<i class="fas fa-stream text-indigo-600 dark:text-indigo-400"></i>
</div>
<h2 class="text-base font-semibold text-text-light dark:text-text-dark">{{ _('Recent Activity') }}</h2>
</div>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
<span class="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full mr-1.5"></span>
{{ _('Live') }}
</span>
</div>
<div id="activityTimeline" class="activity-timeline">
{% if recent_activities %}
{% for activity in recent_activities %}
<div class="activity-timeline-item group">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 relative">
<div class="w-8 h-8 rounded-full bg-indigo-500/20 dark:bg-indigo-400/20 flex items-center justify-center">
<i class="fas fa-circle text-xs text-indigo-500 dark:text-indigo-400"></i>
</div>
{% if not loop.last %}
<div class="absolute left-1/2 top-8 w-0.5 h-4 bg-border-light dark:bg-border-dark transform -translate-x-1/2"></div>
{% endif %}
</div>
<div class="flex-1 pb-4 group-last:pb-0">
<p class="text-sm font-medium text-text-light dark:text-text-dark mb-0.5">{{ activity.description or 'Activity' }}</p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-clock mr-1"></i>{{ activity.created_at|local_datetime_short }}
</p>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-8">
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('No recent activity') }}</p>
</div>
{% endif %}
</div>
</div>
{% if (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
<!-- Support TimeTracker Widget - compact, below fold -->
<div class="bg-card-light dark:bg-card-dark border border-amber-200 dark:border-amber-800 p-5 rounded-xl shadow-sm dashboard-widget">
<div class="flex items-center gap-2 mb-3">
<div class="bg-amber-500/10 dark:bg-amber-400/10 p-2 rounded-lg">
<i class="fas fa-mug-saucer text-amber-600 dark:text-amber-400"></i>
</div>
<h2 class="text-base font-semibold text-text-light dark:text-text-dark">{{ _('Support TimeTracker') }}</h2>
</div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">
{{ _('Support updates and new features. One-time key removes prompts for this instance.') }}
</p>
{% if time_entries_count > 0 or total_hours > 0 %}
<div class="bg-background-light dark:bg-background-dark rounded-lg p-2 mb-3 text-xs text-text-muted-light dark:text-text-muted-dark space-y-1">
{% if time_entries_count > 0 %}
<div><i class="fas fa-check-circle text-green-500 mr-1"></i>{{ _('You\'ve tracked %(count)s time entries', count=time_entries_count) }}</div>
{% endif %}
{% if total_hours > 0 %}
<div><i class="fas fa-check-circle text-green-500 mr-1"></i>{{ _('You\'ve logged %(hours)s hours', hours="%.1f"|format(total_hours)) }}</div>
{% endif %}
</div>
{% endif %}
<div class="flex flex-col sm:flex-row gap-2">
<a href="{{ url_for('main.donate') }}" class="inline-flex items-center justify-center flex-1 bg-amber-500 hover:bg-amber-600 text-white px-3 py-2 rounded-lg font-medium text-sm transition-colors">
<i class="fas fa-heart mr-1.5"></i>{{ _('Support / Get key') }}
</a>
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=dashboard&utm_campaign=support" target="_blank" rel="noopener noreferrer" onclick="trackDonationClick('dashboard_widget')" class="inline-flex items-center justify-center flex-1 border border-amber-500 text-amber-600 dark:text-amber-400 px-3 py-2 rounded-lg font-medium text-sm hover:bg-amber-500/10 transition-colors">
<i class="fas fa-mug-saucer mr-1.5"></i>{{ _('Donate') }} <i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
</div>
<p class="mt-2 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Remove prompts with a one-time key.') }}
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" onclick="trackDonationClick('dashboard_widget_key')" class="underline hover:no-underline font-medium text-amber-600 dark:text-amber-400">{{ _('Get key') }}</a>
</p>
</div>
{% endif %}
</div>
<!-- Delete Entry Confirmation Dialogs -->
{% for entry in recent_entries %}
{% if current_user.is_admin or entry.user_id == current_user.id %}
{{ confirm_dialog(
'confirmDeleteEntry-' ~ entry.id,
'Delete Time Entry',
'Are you sure you want to delete this time entry? This action cannot be undone.',
'Delete',
'Cancel',
'danger'
) }}
{% endif %}
{% endfor %}
<!-- Start Timer Modal -->
<div id="startTimerModal"
class="fixed inset-0 z-50 hidden overflow-y-auto"
aria-hidden="true"
data-last-timer-context="{{ (last_timer_context|tojson)|e if last_timer_context else '{}' }}"
data-tasks-api-url="{{ url_for('api.get_project_tasks', project_id=0) }}"
data-create-task-url="{{ url_for('api.create_task_inline') }}"
data-no-task="{{ _('No task')|e }}"
data-create-new-task="{{ _('Create new task...')|e }}"
data-loading-tasks="{{ _('Loading tasks...')|e }}"
data-new-task-prompt="{{ _('Enter new task name:')|e }}"
data-task-create-failed="{{ _('Failed to create task: ')|e }}"
data-select-project-client="{{ _('Please select either a project or a client')|e }}"
data-complete-task-create="{{ _('Please complete creating the task or select an existing task')|e }}"
data-error-title="{{ _('Error')|e }}"
data-notes-placeholder="{{ _('What are you working on?')|e }}"
data-only-one-client="{{ 'true' if only_one_client|default(false) else 'false' }}"
data-single-client-id="{{ single_client.id if single_client else '' }}"
data-require-task="{{ 'true' if getattr(settings, 'time_entry_require_task', false) else 'false' }}"
data-require-description="{{ 'true' if getattr(settings, 'time_entry_require_description', false) else 'false' }}"
data-description-min-length="{{ getattr(settings, 'time_entry_description_min_length', 20) }}"
data-task-required-msg="{{ _('A task must be selected when logging time for a project')|e }}"
data-description-required-msg="{{ _('A description is required when logging time')|e }}"
data-description-min-msg="{{ _('Description must be at least %(min)s characters', min=getattr(settings, 'time_entry_description_min_length', 20))|e }}">
<div class="absolute inset-0 bg-black/50" data-overlay></div>
<div class="relative max-w-lg mx-auto mt-24 max-h-[90vh] overflow-y-auto bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-lg" data-modal-content>
<div class="p-4 border-b border-border-light dark:border-border-dark flex items-center justify-between gap-2">
<div class="text-lg font-semibold">{{ _('Start Timer') }}</div>
<div class="flex items-center gap-2 shrink-0">
<button type="submit" form="startTimerForm" id="startTimerSubmitBtn" class="btn btn-primary btn-sm">{{ _('Start') }}</button>
<button type="button" data-close class="px-2 py-1 text-sm hover:bg-background-light dark:hover:bg-background-dark rounded">{{ _('Close') }}</button>
</div>
</div>
<form method="POST" action="{{ url_for('timer.start_timer') }}" id="startTimerForm" class="p-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-4">
{% if templates %}
<div class="pb-3 border-b border-border-light dark:border-border-dark">
<label class="form-label">{{ _('Quick start with a template') }}</label>
<div class="flex flex-wrap gap-2 mt-2">
{% for template in templates %}
<button type="button"
onclick="applyTemplate({{ template.id }})"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-primary/30 bg-primary/5 dark:bg-primary/10 hover:bg-primary/15 dark:hover:bg-primary/20 text-primary font-medium text-sm transition">
<i class="fas fa-bolt text-xs"></i>
{{ template.name }}{% if template.project %} · {{ template.project.name }}{% endif %}
</button>
{% endfor %}
<a href="{{ url_for('time_entry_templates.list_templates') }}" class="inline-flex items-center gap-1 px-3 py-2 text-sm text-text-muted-light dark:text-text-muted-dark hover:text-primary transition">
{{ _('All templates') }} <i class="fas fa-external-link-alt text-xs"></i>
</a>
</div>
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="startTimerProject" class="form-label">{{ _('Project') }}</label>
<select id="startTimerProject" name="project_id" class="form-input">
<option value="">{{ _('Select a project (optional)') }}</option>
{% for project in active_projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="startTimerClient" class="form-label">{{ _('Client') }}</label>
{{ client_select('client_id', active_clients, required=False, only_one_client=only_one_client|default(false), single_client=single_client, id='startTimerClient') }}
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
{{ _('Select either a project or a client') }}
</p>
</div>
</div>
<div>
<label for="startTimerTask" class="form-label">{% if getattr(settings, 'time_entry_require_task', false) %}{{ _('Task') }} *{% else %}{{ _('Task (optional)') }}{% endif %}</label>
<select id="startTimerTask" name="task_id" class="form-input w-full">
<option value="">{{ _('No task') }}</option>
<!-- Options populated dynamically when project is selected -->
</select>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
{{ _('Select a project first to load tasks, or choose "Create new task..." to add one') }}
</p>
</div>
<div>
<label for="startTimerNotes" class="form-label">{% if getattr(settings, 'time_entry_require_description', false) %}{{ _('Notes') }} *{% else %}{{ _('Notes (optional)') }}{% endif %}</label>
<div class="markdown-editor-wrapper">
<textarea id="startTimerNotes" name="notes" class="hidden" placeholder="{{ _('What are you working on?') }}"></textarea>
<div id="startTimerNotes_editor"></div>
</div>
</div>
<div>
<label for="startTimerTags" class="form-label">{{ _('Tags (optional)') }}</label>
<input type="text" id="startTimerTags" name="tags" class="form-input w-full" placeholder="{{ _('e.g. meeting, dev, admin') }}"
list="startTimerTagsList" autocomplete="off">
<datalist id="startTimerTagsList">
{% for tag in recent_tags %}
<option value="{{ tag|e }}"></option>
{% endfor %}
</datalist>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Comma-separated; recent tags shown as suggestions.') }}</p>
</div>
</div>
</form>
</div>
</div>
{% set create_task_modal_content %}
<div class="space-y-3">
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Enter a name for the new task.') }}</p>
<input type="text" id="createTaskNameInput" class="form-input w-full" placeholder="{{ _('Task name') }}">
<p id="createTaskError" class="text-sm text-red-600 hidden"></p>
</div>
{% endset %}
{% set create_task_modal_footer %}
<button type="button" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" data-create-task-cancel>{{ _('Cancel') }}</button>
<button type="button" class="btn btn-primary" data-create-task-confirm>{{ _('Create') }}</button>
{% endset %}
{{ modal('createTaskModal', _('Create task'), create_task_modal_content, create_task_modal_footer, 'sm') }}
{% endblock %}
{% block scripts_extra %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Live timer: update elapsed every second when a timer is running; also live Today/Week/Month hours
(function initLiveTimer() {
var timerWidget = document.querySelector('[data-timer-start]');
if (!timerWidget) return;
var startIso = timerWidget.getAttribute('data-timer-start');
if (!startIso) return;
var startTime = new Date(startIso).getTime();
var todayHours = parseFloat(timerWidget.getAttribute('data-today-hours')) || 0;
var weekHours = parseFloat(timerWidget.getAttribute('data-week-hours')) || 0;
var monthHours = parseFloat(timerWidget.getAttribute('data-month-hours')) || 0;
var startDate = new Date(startIso);
function formatElapsed(seconds) {
var h = Math.floor(seconds / 3600);
var m = Math.floor((seconds % 3600) / 60);
var s = seconds % 60;
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
}
function tick() {
var now = Date.now();
var elapsedSec = (now - startTime) / 1000;
var elapsedHours = elapsedSec / 3600;
var display = formatElapsed(Math.floor(elapsedSec));
var elapsedEl = document.getElementById('dashboard-timer-elapsed');
if (elapsedEl) elapsedEl.textContent = display;
document.querySelectorAll('.dashboard-live-duration').forEach(function(span) {
span.textContent = display;
});
var nowDate = new Date(now);
var sameDay = startDate.getFullYear() === nowDate.getFullYear() && startDate.getMonth() === nowDate.getMonth() && startDate.getDate() === nowDate.getDate();
var startWeek = new Date(startDate); startWeek.setDate(startWeek.getDate() - startWeek.getDay());
var nowWeek = new Date(nowDate); nowWeek.setDate(nowWeek.getDate() - nowWeek.getDay());
var sameWeek = startWeek.getTime() === nowWeek.getTime();
var sameMonth = startDate.getFullYear() === nowDate.getFullYear() && startDate.getMonth() === nowDate.getMonth();
var todayEl = document.getElementById('todayHoursValue');
var weekEl = document.getElementById('weekHoursValue');
var monthEl = document.getElementById('monthHoursValue');
if (todayEl && sameDay) todayEl.textContent = (todayHours + elapsedHours).toFixed(2);
if (weekEl && sameWeek) weekEl.textContent = (weekHours + elapsedHours).toFixed(2);
if (monthEl && sameMonth) monthEl.textContent = (monthHours + elapsedHours).toFixed(2);
}
tick();
setInterval(tick, 1000);
})();
try {
if (typeof anime !== 'undefined') {
anime({
targets: '.animated-card',
translateY: [20, 0],
opacity: [0, 1],
delay: (window.anime && anime.stagger) ? anime.stagger(100) : undefined,
duration: 500,
easing: 'easeOutQuad'
});
}
} catch(e) { /* no-op if animation lib missing */ }
const modal = document.getElementById('startTimerModal');
// Open modal: direct binding (reliable) and delegation (backup)
function openStartTimerModal(e) {
if (e) e.preventDefault();
if (modal) {
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
try {
const projectSelect = document.getElementById('startTimerProject');
if (projectSelect && projectSelect.value) {
loadTasksForProject(projectSelect.value);
}
} catch (_) {}
}
}
document.querySelectorAll('.js-open-start-timer').forEach(function(openBtn) {
if (openBtn && modal) openBtn.addEventListener('click', openStartTimerModal);
});
if (modal) {
const closeBtn = modal.querySelector('[data-close]');
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
});
}
// Close on overlay click
modal.addEventListener('click', (e) => {
if (e.target === modal || e.target.hasAttribute('data-overlay')) {
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
}
});
const modalContent = modal.querySelector('[data-modal-content]');
if (modalContent) {
modalContent.addEventListener('click', (e) => {
// Close button: handle here since stopPropagation would block modal handler
if (e.target.closest('[data-close]')) {
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
e.preventDefault();
return;
}
e.stopPropagation(); // Prevent overlay-close when clicking card
});
}
const projectSelect = document.getElementById('startTimerProject');
const clientSelect = document.getElementById('startTimerClient');
const taskSelect = document.getElementById('startTimerTask');
const createNewTaskValue = '__new__';
const tasksApiUrlTemplate = modal?.dataset?.tasksApiUrl || '';
const createTaskUrl = modal?.dataset?.createTaskUrl || '';
const noTaskText = modal?.dataset?.noTask || 'No task';
const createNewTaskText = modal?.dataset?.createNewTask || 'Create new task...';
const loadingTasksText = modal?.dataset?.loadingTasks || 'Loading tasks...';
const newTaskPromptText = modal?.dataset?.newTaskPrompt || 'Enter new task name:';
const taskCreateFailedText = modal?.dataset?.taskCreateFailed || 'Failed to create task: ';
const selectProjectOrClientText = modal?.dataset?.selectProjectClient || 'Please select either a project or a client';
const completeTaskCreationText = modal?.dataset?.completeTaskCreate || 'Please complete creating the task or select an existing task';
const errorTitleText = modal?.dataset?.errorTitle || 'Error';
const taskNameRequiredText = '{{ _("Task name is required.") }}';
const createTaskModal = document.getElementById('createTaskModal');
const createTaskNameInput = document.getElementById('createTaskNameInput');
const createTaskError = document.getElementById('createTaskError');
const createTaskConfirmBtn = createTaskModal ? createTaskModal.querySelector('[data-create-task-confirm]') : null;
const createTaskCancelBtn = createTaskModal ? createTaskModal.querySelector('[data-create-task-cancel]') : null;
let createTaskModalResolver = null;
function closeCreateTaskModal(result) {
if (!createTaskModal) return;
createTaskModal.classList.add('hidden');
createTaskModal.setAttribute('aria-hidden', 'true');
if (createTaskModalResolver) {
createTaskModalResolver(result);
createTaskModalResolver = null;
}
}
function openCreateTaskModal() {
if (!createTaskModal || !createTaskNameInput) {
return Promise.resolve(null);
}
if (createTaskError) {
createTaskError.textContent = '';
createTaskError.classList.add('hidden');
}
createTaskNameInput.value = '';
createTaskModal.classList.remove('hidden');
createTaskModal.setAttribute('aria-hidden', 'false');
setTimeout(() => createTaskNameInput.focus(), 0);
return new Promise((resolve) => {
createTaskModalResolver = resolve;
});
}
if (createTaskConfirmBtn && createTaskNameInput) {
createTaskConfirmBtn.addEventListener('click', () => {
const name = createTaskNameInput.value.trim();
if (!name) {
if (createTaskError) {
createTaskError.textContent = taskNameRequiredText;
createTaskError.classList.remove('hidden');
}
return;
}
closeCreateTaskModal(name);
});
}
if (createTaskCancelBtn) {
createTaskCancelBtn.addEventListener('click', () => closeCreateTaskModal(null));
}
if (createTaskModal) {
createTaskModal.addEventListener('click', (e) => {
if (e.target === createTaskModal) {
closeCreateTaskModal(null);
}
});
}
if (createTaskNameInput) {
createTaskNameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (createTaskConfirmBtn) createTaskConfirmBtn.click();
} else if (e.key === 'Escape') {
e.preventDefault();
closeCreateTaskModal(null);
}
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && createTaskModal && !createTaskModal.classList.contains('hidden')) {
closeCreateTaskModal(null);
}
});
function buildTasksUrl(projectId) {
const pid = String(projectId || '').trim();
if (!pid) return tasksApiUrlTemplate;
return String(tasksApiUrlTemplate).replace(/\/0\/tasks$/, '/' + encodeURIComponent(pid) + '/tasks');
}
async function loadTasksForProject(pid, preserveTaskId = null) {
if (!taskSelect) return;
if (!pid) {
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
taskSelect.disabled = false;
return;
}
taskSelect.innerHTML = '<option value="">' + loadingTasksText + '</option>';
taskSelect.disabled = true;
try {
const res = await fetch(buildTasksUrl(pid), { credentials: 'same-origin' });
const data = await res.json();
var opts = [];
var o0 = document.createElement('option');
o0.value = '';
o0.textContent = noTaskText;
opts.push(o0);
if (data && data.tasks) {
data.tasks.forEach(function(t) {
var o = document.createElement('option');
o.value = t.id;
o.textContent = t.name || '';
opts.push(o);
});
}
var oNew = document.createElement('option');
oNew.value = createNewTaskValue;
oNew.textContent = createNewTaskText;
opts.push(oNew);
var frag = document.createDocumentFragment();
opts.forEach(function(o) { frag.appendChild(o); });
taskSelect.innerHTML = '';
taskSelect.appendChild(frag);
if (preserveTaskId) {
var found = Array.from(taskSelect.options).some(function(o) { return o.value === String(preserveTaskId); });
if (found) taskSelect.value = String(preserveTaskId);
}
taskSelect.disabled = false;
} catch (err) {
console.error('Failed to load tasks', err);
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
taskSelect.disabled = false;
}
}
// Handle "Create new task..." selection
if (taskSelect) {
taskSelect.addEventListener('change', async function() {
if (this.value !== createNewTaskValue) return;
const pid = projectSelect ? projectSelect.value : '';
if (!pid) {
this.value = '';
return;
}
let taskName = await openCreateTaskModal();
if (taskName === null && !createTaskModal) {
taskName = prompt(newTaskPromptText);
}
if (!taskName || !taskName.trim()) {
this.value = '';
return;
}
try {
const csrfToken = modal ? modal.querySelector('input[name="csrf_token"]')?.value : '';
const res = await fetch(createTaskUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': csrfToken || '' },
credentials: 'same-origin',
body: JSON.stringify({ name: taskName.trim(), project_id: parseInt(pid) })
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to create task');
}
const taskData = await res.json();
if (taskData && taskData.id) {
await loadTasksForProject(pid, taskData.id);
} else {
this.value = '';
}
} catch (err) {
console.error('Failed to create task:', err);
alert(taskCreateFailedText + (err.message || err));
this.value = '';
}
});
}
const onlyOneClient = (modal?.dataset?.onlyOneClient || 'false') === 'true';
const singleClientId = (modal?.dataset?.singleClientId || '') || null;
// Repeat last: pre-fill modal from last_timer_context and open
document.querySelectorAll('.js-repeat-last-timer').forEach(function(btn) {
btn.addEventListener('click', async function(e) {
e.preventDefault();
if (!modal) return;
var ctxRaw = modal.getAttribute('data-last-timer-context');
var ctx = {};
try { ctx = ctxRaw ? JSON.parse(ctxRaw) : {}; } catch (err) { ctx = {}; }
if (!ctx.project_id && !ctx.client_id) {
openStartTimerModal(e);
return;
}
if (projectSelect) projectSelect.value = ctx.project_id || '';
if (clientSelect) clientSelect.value = ctx.client_id || '';
if (ctx.project_id && typeof loadTasksForProject === 'function') {
await loadTasksForProject(ctx.project_id, ctx.task_id || null);
} else if (taskSelect) {
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
taskSelect.disabled = false;
}
var notesEl = document.getElementById('startTimerNotes');
if (notesEl) notesEl.value = ctx.notes || '';
if (window.dashboardNotesEditor && typeof window.dashboardNotesEditor.setMarkdown === 'function') {
try { window.dashboardNotesEditor.setMarkdown(ctx.notes || ''); } catch (err) {}
}
var tagsEl = document.getElementById('startTimerTags');
if (tagsEl && ctx.tags) tagsEl.value = ctx.tags;
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
});
});
// Task loading: attach when project and task elements exist (independent of client)
if (projectSelect && taskSelect) {
projectSelect.addEventListener('change', () => {
const pid = projectSelect.value;
if (pid && clientSelect) {
clientSelect.value = '';
} else if (onlyOneClient && singleClientId) {
clientSelect.value = singleClientId;
}
loadTasksForProject(pid);
});
}
// Client/project mutual exclusivity (when client select exists)
if (clientSelect && taskSelect) {
clientSelect.addEventListener('change', function() {
var cid = clientSelect.value;
if (cid) {
if (projectSelect) projectSelect.value = '';
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
taskSelect.disabled = true;
} else {
// Only enable task select if no project selected (otherwise loadTasksForProject will handle it)
if (!projectSelect || !projectSelect.value) taskSelect.disabled = false;
}
});
}
// Form validation: ensure either project or client is selected
const startTimerForm = modal ? modal.querySelector('form') : null;
if (startTimerForm) {
// Store original button state to restore if needed
const submitBtn = modal.querySelector('#startTimerSubmitBtn');
let originalButtonHTML = submitBtn ? submitBtn.innerHTML : null;
startTimerForm.addEventListener('submit', async function(e) {
e.preventDefault();
// Validate project or client selection
const projectVal = projectSelect ? projectSelect.value : '';
const clientVal = clientSelect ? clientSelect.value : '';
if (!projectVal && !clientVal) {
const errorMsg = selectProjectOrClientText;
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(errorMsg, errorTitleText, 5000);
} else {
alert(errorMsg);
}
return false;
}
// Prevent submit if "Create new task..." is still selected (incomplete flow)
const taskVal = taskSelect ? taskSelect.value : '';
if (taskVal === createNewTaskValue) {
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(completeTaskCreationText, errorTitleText, 5000);
} else {
alert(completeTaskCreationText);
}
return false;
}
// Validate time entry requirements (task, description)
const notesEl = document.getElementById('startTimerNotes');
const requireTask = modal && modal.dataset.requireTask === 'true';
const requireDescription = modal && modal.dataset.requireDescription === 'true';
const descMinLen = modal ? parseInt(modal.dataset.descriptionMinLength || '20', 10) : 20;
if (projectVal && requireTask && !taskVal) {
const msg = modal?.dataset.taskRequiredMsg || 'A task must be selected when logging time for a project';
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(msg, errorTitleText, 5000);
} else {
alert(msg);
}
return false;
}
if (requireDescription) {
let notesVal = notesEl ? notesEl.value : '';
if (window.dashboardNotesEditor && typeof window.dashboardNotesEditor.getMarkdown === 'function') {
try { notesVal = window.dashboardNotesEditor.getMarkdown(); } catch (e) {}
}
const notesTrimmed = (notesVal || '').trim();
if (!notesTrimmed) {
const msg = modal?.dataset.descriptionRequiredMsg || 'A description is required when logging time';
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(msg, errorTitleText, 5000);
} else {
alert(msg);
}
return false;
}
if (notesTrimmed.length < descMinLen) {
const msg = (modal?.dataset.descriptionMinMsg || 'Description must be at least ' + descMinLen + ' characters').replace(/\d+/, String(descMinLen));
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(msg, errorTitleText, 5000);
} else {
alert(msg);
}
return false;
}
}
// Sync Toast UI Editor notes into hidden textarea (programmatic submit does not fire submit event)
if (notesEl && window.dashboardNotesEditor && typeof window.dashboardNotesEditor.getMarkdown === 'function') {
try {
notesEl.value = window.dashboardNotesEditor.getMarkdown();
} catch (err) {
console.error('Failed to sync markdown editor:', err);
}
}
// Now submit the form normally
startTimerForm.submit();
}, true); // Use capture phase to run before other handlers
}
}
// Template application function
window.applyTemplate = async function(templateId) {
try {
const response = await fetch(`/api/templates/${templateId}`, { credentials: 'same-origin' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const template = await response.json();
// Get form elements (re-select to avoid scope issues)
const projectSelect = document.getElementById('startTimerProject');
const taskSelect = document.getElementById('startTimerTask');
const notesField = document.getElementById('startTimerNotes');
if (!projectSelect || !taskSelect || !notesField) {
throw new Error('Form elements not found');
}
// Apply template values to form
if (template.project_id) {
projectSelect.value = template.project_id;
// Trigger change event to load tasks
projectSelect.dispatchEvent(new Event('change'));
// Wait for tasks to load, then select task
setTimeout(() => {
if (template.task_id) {
const found = Array.from(taskSelect.options).some(o => o.value === String(template.task_id));
if (found) taskSelect.value = String(template.task_id);
}
}, 350);
}
if (template.default_notes) {
notesField.value = template.default_notes;
// Update markdown editor if it exists
if (window.dashboardNotesEditor && typeof window.dashboardNotesEditor.setMarkdown === 'function') {
try {
window.dashboardNotesEditor.setMarkdown(template.default_notes);
} catch (e) {}
}
}
// Mark template as used
const csrfToken = modal ? modal.querySelector('input[name="csrf_token"]')?.value : '';
fetch(`/api/templates/${templateId}/use`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken || ''
}
}).catch(() => {}); // Silently fail if marking fails
} catch (error) {
console.error('Error applying template:', error);
alert('Failed to apply template. Please try again.');
}
};
// Initialize Toast UI Editor for timer notes
const notesInput = document.getElementById('startTimerNotes');
const notesPlaceholderText = document.getElementById('startTimerModal')?.dataset?.notesPlaceholder || 'What are you working on?';
if (notesInput && window.toastui && window.toastui.Editor) {
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
window.dashboardNotesEditor = new toastui.Editor({
el: document.getElementById('startTimerNotes_editor'),
height: '250px',
initialEditType: 'wysiwyg',
previewStyle: 'vertical',
usageStatistics: false,
hideModeSwitch: false,
placeholder: notesPlaceholderText,
theme: theme,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task'],
['link', 'code', 'codeblock', 'table'],
['image'],
['scrollSync']
],
initialValue: notesInput.value || ''
});
// Apply theme changes dynamically if supported
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class' && window.dashboardNotesEditor) {
const nextTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
try {
if (typeof window.dashboardNotesEditor.setTheme === 'function') {
window.dashboardNotesEditor.setTheme(nextTheme);
}
} catch (e) {}
}
});
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
// Multiple image upload handler - improved version
window.dashboardNotesEditor.removeHook && window.dashboardNotesEditor.removeHook('addImageBlobHook');
// Create custom multiple file input with unique ID
const dashboardFileInputId = 'dashboard-image-input-' + Date.now();
let dashboardFileInput = document.getElementById(dashboardFileInputId);
if (!dashboardFileInput) {
dashboardFileInput = document.createElement('input');
dashboardFileInput.id = dashboardFileInputId;
dashboardFileInput.type = 'file';
dashboardFileInput.accept = 'image/*';
dashboardFileInput.setAttribute('multiple', 'multiple'); // Use setAttribute to ensure it's set
dashboardFileInput.style.display = 'none';
dashboardFileInput.style.position = 'absolute';
dashboardFileInput.style.left = '-9999px';
document.body.appendChild(dashboardFileInput);
}
// Verify multiple attribute is set
if (!dashboardFileInput.hasAttribute('multiple')) {
dashboardFileInput.setAttribute('multiple', 'multiple');
}
console.log('File input multiple attribute:', dashboardFileInput.multiple, dashboardFileInput.hasAttribute('multiple'));
// Function to find image button with multiple strategies
function findImageButton(toolbar, retries = 0) {
if (!toolbar) return null;
// Try multiple selectors in order of reliability
const selectors = [
'[data-tooltip="Insert image"]',
'[data-tooltip*="image" i]',
'[aria-label*="image" i]',
'.toastui-editor-toolbar-icons.image',
'button[class*="image"]',
'.toastui-editor-toolbar-group button:nth-child(5)', // Common position for image button
'.toastui-editor-toolbar-group button:nth-child(6)',
'.toastui-editor-toolbar-group button:nth-child(4)'
];
for (const selector of selectors) {
try {
const button = toolbar.querySelector(selector);
if (button) {
// Verify it's likely the image button by checking if it has image-related attributes or classes
const buttonText = (button.textContent || '').toLowerCase();
const buttonTitle = (button.title || button.getAttribute('title') || '').toLowerCase();
if (buttonText.includes('image') || buttonTitle.includes('image') ||
selector.includes('image') || button.classList.toString().includes('image')) {
return button;
}
}
} catch (e) {
// Continue to next selector
}
}
return null;
}
// Function to intercept image button with retry logic
function setupImageButtonInterception(editor, input, maxRetries = 5) {
let attempts = 0;
let intercepted = false;
function tryIntercept() {
attempts++;
try {
// Get the editor's root element - try multiple ways
const editorContainer = document.getElementById('startTimerNotes_editor');
if (!editorContainer) {
throw new Error('Editor container element not found');
}
// Find toolbar - try multiple ways to access it
let toolbar = null;
// Try finding toolbar relative to editor container
let editorWrapper = null;
if (editorContainer.closest) {
editorWrapper = editorContainer.closest('.toastui-editor');
}
if (!editorWrapper && editorContainer.parentElement) {
editorWrapper = editorContainer.parentElement;
}
if (editorWrapper) {
toolbar = editorWrapper.querySelector('.toastui-editor-toolbar');
}
// Fallback: search entire document
if (!toolbar) {
toolbar = document.querySelector('.toastui-editor-toolbar');
}
if (toolbar && !intercepted) {
const imageButton = findImageButton(toolbar, attempts);
if (imageButton) {
// Use capture phase to intercept before ToastUI's handler
imageButton.addEventListener('click', function handler(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// Small delay to ensure default behavior is prevented
setTimeout(() => {
input.click();
}, 10);
return false;
}, true); // Use capture phase
intercepted = true;
console.log('Successfully intercepted image button for multiple file selection');
return true;
}
}
} catch (err) {
console.warn('Error intercepting image button (attempt ' + attempts + '):', err);
}
// Retry if we haven't exceeded max retries
if (attempts < maxRetries && !intercepted) {
setTimeout(tryIntercept, 200 * attempts); // Exponential backoff
return false;
} else if (!intercepted) {
console.warn('Could not intercept image button after ' + maxRetries + ' attempts. Multiple image selection may not work via toolbar button.');
return false;
}
return false;
}
// Start with initial delay to ensure toolbar is ready
setTimeout(tryIntercept, 100);
}
// Override the addImageBlobHook to use multiple file selection
// This is the fallback if button interception fails
if (window.dashboardNotesEditor && typeof window.dashboardNotesEditor.addHook === 'function') {
window.dashboardNotesEditor.addHook('addImageBlobHook', async function(blob, callback) {
// Prevent the default single file behavior
// Trigger our multiple file input instead
setTimeout(function() {
dashboardFileInput.click();
}, 10);
// Don't call callback - let dashboardFileInput.change handle it
});
}
// Setup button interception
if (window.dashboardNotesEditor) {
setupImageButtonInterception(window.dashboardNotesEditor, dashboardFileInput);
}
// Handle multiple file selection with progress feedback
dashboardFileInput.addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
console.log('File input changed. Files selected:', files.length, 'Multiple attribute:', dashboardFileInput.multiple);
if (files.length === 0) {
console.log('No files selected');
return;
}
// Log file details for debugging
files.forEach((file, index) => {
console.log(`File ${index + 1}: ${file.name}, type: ${file.type}, size: ${file.size}`);
});
// Validate file types
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
const validFiles = files.filter(file => {
const isValid = allowedTypes.includes(file.type) || /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name);
if (!isValid) {
console.warn('Skipping invalid file type:', file.name, file.type);
}
return isValid;
});
if (validFiles.length === 0) {
alert('{{ _("No valid image files selected. Please select PNG, JPG, GIF, or WebP images.") }}');
dashboardFileInput.value = '';
return;
}
if (validFiles.length < files.length) {
console.warn('Some files were skipped due to invalid type');
}
// Show loading feedback
const fileCount = validFiles.length;
const loadingMsg = fileCount > 1 ?
`{{ _("Uploading") }} ${fileCount} {{ _("images") }}...` :
'{{ _("Uploading image") }}...';
// Try to show a temporary loading indicator (if toast/notification system exists)
let loadingIndicator = null;
try {
// Check if there's a toast/notification system we can use
if (window.showToast || window.showNotification) {
const showToast = window.showToast || window.showNotification;
loadingIndicator = showToast(loadingMsg, 'info', { duration: 0 });
}
} catch (e) {
// No toast system available, continue without it
}
try {
const formData = new FormData();
validFiles.forEach(file => {
formData.append('images', file);
});
const res = await fetch('{{ url_for('api.upload_editor_images_bulk') }}', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data && data.urls && data.urls.length > 0) {
// Insert all images into editor
const imagesMarkdown = data.urls.map((url, index) => {
const fileName = validFiles[index].name || 'image';
return `![${fileName}](${url})`;
}).join('\n\n');
// Get current markdown content
const currentMarkdown = window.dashboardNotesEditor.getMarkdown() || '';
// Append images to current content (with proper spacing)
const newMarkdown = currentMarkdown
? currentMarkdown + '\n\n' + imagesMarkdown
: imagesMarkdown;
// Set the markdown which will properly render images in WYSIWYG mode
window.dashboardNotesEditor.setMarkdown(newMarkdown);
// Show success message
const successMsg = data.urls.length > 1 ?
`{{ _("Successfully uploaded") }} ${data.urls.length} {{ _("images") }}` :
'{{ _("Image uploaded successfully") }}';
if (loadingIndicator && typeof loadingIndicator.update === 'function') {
loadingIndicator.update(successMsg, 'success');
setTimeout(() => loadingIndicator.remove && loadingIndicator.remove(), 2000);
}
// Show warnings if any
if (data.warnings && data.warnings.length > 0) {
console.warn('Some images failed to upload:', data.warnings);
const warningMsg = `{{ _("Warning") }}: ${data.warnings.length} {{ _("image(s) failed to upload") }}`;
if (window.showToast || window.showNotification) {
const showToast = window.showToast || window.showNotification;
showToast(warningMsg, 'warning');
} else {
alert(warningMsg);
}
}
} else {
const errorMsg = data.error || '{{ _("Failed to upload images. Please try again.") }}';
console.error('Upload failed', data);
if (loadingIndicator && typeof loadingIndicator.remove === 'function') {
loadingIndicator.remove();
}
alert(errorMsg);
}
} catch (error) {
console.error('Multiple image upload error', error);
if (loadingIndicator && typeof loadingIndicator.remove === 'function') {
loadingIndicator.remove();
}
alert('{{ _("Failed to upload images. Please check your connection and try again.") }}');
}
// Reset file input
dashboardFileInput.value = '';
});
}
});
</script>
<!-- Toast UI Editor -->
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
{% endblock %}