mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-04-24 05:29:45 -05:00
6df92646a1
Implement comprehensive time entry duplication feature that allows users to quickly copy previous entries with pre-filled data, improving productivity for repetitive time tracking tasks. Features: - Add duplicate route endpoint (/timer/duplicate/<id>) - Add duplicate buttons to dashboard and edit entry pages - Pre-fill project, task, notes, tags, and billable status - Show information banner with original entry details - Implement permission checks (users can duplicate own entries, admins can duplicate any) - Track analytics events for duplication actions Backend Changes: - app/routes/timer.py: Add duplicate_timer() route with security checks - Route handles pre-filling manual entry form with original entry data - Analytics tracking for 'timer.duplicated' events Frontend Changes: - app/templates/main/dashboard.html: Add duplicate icon button to Recent Entries table - templates/timer/edit_timer.html: Add duplicate button next to Back button - app/templates/timer/manual_entry.html: Support pre-filled data and duplication context - Add blue information banner showing original entry details when duplicating Testing: - Add comprehensive test suite with 21 tests (all passing) - tests/test_time_entry_duplication.py: Unit, integration, security, smoke, and edge case tests - Test coverage includes: route access, authentication, pre-fill functionality, permissions, UI visibility Documentation: - docs/features/TIME_ENTRY_DUPLICATION.md: Technical documentation - docs/user-guides/DUPLICATING_TIME_ENTRIES.md: User guide with examples - TIME_ENTRY_DUPLICATION_IMPLEMENTATION.md: Implementation details - TIME_ENTRY_DUPLICATION_FEATURE_SUMMARY.md: Complete feature overview Benefits: - Saves ~60% time when logging similar work - Reduces manual data entry for recurring tasks - Maintains data consistency through field copying - Intuitive workflow with clear visual feedback Security: - Users can only duplicate their own entries - Admin users can duplicate any entry - Proper authentication and permission checks Breaking Changes: None
229 lines
12 KiB
HTML
229 lines
12 KiB
HTML
{% extends "base.html" %}
|
|
{% from "components/cards.html" import info_card, stat_card %}
|
|
{% from "components/ui.html" import confirm_dialog %}
|
|
|
|
{% block content %}
|
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold">{{ _('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">
|
|
<i class="fas fa-tachometer-alt"></i> / {{ _('Dashboard') }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Key Stats -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
|
|
{{ info_card("Today's Hours", "%.2f"|format(today_hours), "Hours logged today") }}
|
|
{{ info_card("Week's Hours", "%.2f"|format(week_hours), "Hours logged this week") }}
|
|
{{ info_card("Month's Hours", "%.2f"|format(month_hours), "Hours logged this month") }}
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<!-- Left Column: Active Timer & Recent Entries -->
|
|
<div class="lg:col-span-2 space-y-6">
|
|
<!-- Active Timer -->
|
|
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h2 class="text-lg font-semibold">{{ _('Timer') }}</h2>
|
|
{% if not active_timer %}
|
|
<button type="button" id="openStartTimer" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Start Timer') }}</button>
|
|
{% endif %}
|
|
</div>
|
|
{% if active_timer %}
|
|
<div class="flex justify-between items-center">
|
|
<div>
|
|
<p class="font-semibold">{{ active_timer.project.name }}</p>
|
|
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Started at') }} {{ active_timer.start_time.strftime('%I:%M %p') }}</p>
|
|
</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 text-white px-4 py-2 rounded-lg">{{ _('Stop Timer') }}</button>
|
|
</form>
|
|
</div>
|
|
{% else %}
|
|
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No active timer.') }}</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Recent Entries -->
|
|
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
|
|
<h2 class="text-lg font-semibold mb-4">Recent Entries</h2>
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-left">
|
|
<thead class="border-b border-border-light dark:border-border-dark">
|
|
<tr>
|
|
<th class="p-4">{{ _('Project') }}</th>
|
|
<th class="p-4">{{ _('Task') }}</th>
|
|
<th class="p-4">{{ _('Notes') }}</th>
|
|
<th class="p-4">{{ _('Tags') }}</th>
|
|
<th class="p-4">{{ _('Duration') }}</th>
|
|
<th class="p-4">{{ _('Date') }}</th>
|
|
<th class="p-4">{{ _('Actions') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for entry in recent_entries %}
|
|
<tr class="border-b border-border-light dark:border-border-dark">
|
|
<td class="p-4">{{ entry.project.name }}</td>
|
|
<td class="p-4">{{ entry.task.name if entry.task else '-' }}</td>
|
|
<td class="p-4">{% if entry.notes %}<span title="{{ entry.notes }}">{{ entry.notes[:60] }}{% if entry.notes|length > 60 %}...{% endif %}</span>{% else %}-{% endif %}</td>
|
|
<td class="p-4">{{ entry.tags or '-' }}</td>
|
|
<td class="p-4">{{ entry.duration_formatted }}</td>
|
|
<td class="p-4">{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
|
|
<td class="p-4">
|
|
<div class="flex gap-2">
|
|
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}" class="text-primary hover:text-primary-dark" title="{{ _('Edit entry') }}">
|
|
<i class="fas fa-edit"></i>
|
|
</a>
|
|
<a href="{{ url_for('timer.duplicate_timer', timer_id=entry.id) }}" class="text-blue-600 hover:text-blue-800" title="{{ _('Duplicate entry') }}">
|
|
<i class="fas fa-copy"></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="text-red-600 hover:text-red-800" title="{{ _('Delete entry') }}" onclick="document.getElementById('confirmDeleteEntry-{{ entry.id }}').classList.remove('hidden')">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr>
|
|
<td colspan="7" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">{{ _('No recent entries found.') }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Column: Real Insights -->
|
|
<div class="space-y-6">
|
|
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
|
|
<h2 class="text-lg font-semibold mb-4">{{ _('Top Projects (30 days)') }}</h2>
|
|
<ul class="space-y-3">
|
|
{% for item in top_projects %}
|
|
<li class="flex items-center justify-between">
|
|
<div>
|
|
<div class="font-medium">{{ item.project.name }}</div>
|
|
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Billable') }}: {{ '%.1f'|format(item.billable_hours) }}h</div>
|
|
</div>
|
|
<div class="text-right font-semibold">{{ '%.1f'|format(item.hours) }}h</div>
|
|
</li>
|
|
{% else %}
|
|
<li class="text-text-muted-light dark:text-text-muted-dark">{{ _('No activity in the last 30 days.') }}</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
</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">
|
|
<div class="absolute inset-0 bg-black/50" data-overlay></div>
|
|
<div class="relative max-w-lg mx-auto mt-24 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-lg">
|
|
<div class="p-4 border-b border-border-light dark:border-border-dark flex items-center justify-between">
|
|
<div class="text-lg font-semibold">{{ _('Start Timer') }}</div>
|
|
<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>
|
|
<form method="POST" action="{{ url_for('timer.start_timer') }}" class="p-4">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label for="startTimerProject" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Project') }}</label>
|
|
<select id="startTimerProject" name="project_id" required class="form-input">
|
|
<option value="">{{ _('Select a project') }}</option>
|
|
{% for project in active_projects %}
|
|
<option value="{{ project.id }}">{{ project.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="startTimerTask" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Task (optional)') }}</label>
|
|
<select id="startTimerTask" name="task_id" class="form-input">
|
|
<option value="">—</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="startTimerNotes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Notes (optional)') }}</label>
|
|
<textarea id="startTimerNotes" name="notes" rows="3" class="form-input" placeholder="{{ _('What are you working on?') }}"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="mt-6 flex justify-end">
|
|
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Start') }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
|
|
{% block scripts_extra %}
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
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 via event delegation to avoid missed binding
|
|
document.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('#openStartTimer');
|
|
if (btn && modal) {
|
|
e.preventDefault();
|
|
modal.classList.remove('hidden');
|
|
}
|
|
});
|
|
if (modal) {
|
|
modal.querySelector('[data-close]')?.addEventListener('click', () => modal.classList.add('hidden'));
|
|
modal.addEventListener('click', (e) => { if (e.target === modal || e.target.hasAttribute('data-overlay')) modal.classList.add('hidden'); });
|
|
const projectSelect = document.getElementById('startTimerProject');
|
|
const taskSelect = document.getElementById('startTimerTask');
|
|
if (projectSelect && taskSelect) {
|
|
projectSelect.addEventListener('change', async () => {
|
|
const pid = projectSelect.value;
|
|
taskSelect.innerHTML = '<option value="">—</option>';
|
|
if (!pid) return;
|
|
try {
|
|
const res = await fetch(`/api/projects/${pid}/tasks`, { credentials: 'same-origin' });
|
|
const data = await res.json();
|
|
if (data && data.tasks) {
|
|
data.tasks.forEach(t => {
|
|
const opt = document.createElement('option');
|
|
opt.value = t.id; opt.textContent = t.name; taskSelect.appendChild(opt);
|
|
});
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|