Files
TimeTracker/app/templates/base.html
T
Dries Peeters 84e2096602 feat: enhance CI/CD workflows and improve UX features
This commit improves the testing workflow, CI/CD documentation, and user experience:

## CI/CD Improvements:
- Add comprehensive testing strategy documentation to CD release workflow
- Document workflow triggers and testing approach in ci-comprehensive.yml
- Update CI/CD documentation with testing workflow details

## UX Enhancements:
- Add localStorage persistence for PWA install prompt dismissal
- Prevent repeated PWA install prompts after user dismisses
- Add dismiss button (×) to PWA install toast notification

## Dashboard Features:
- Add edit and delete actions to recent time entries table
- Include delete confirmation dialogs for time entries
- Add notes field to "Start Timer" modal
- Improve table layout with actions column

## Documentation:
- Create TESTING_WORKFLOW_STRATEGY.md for comprehensive testing guidelines
- Add QUICK_REFERENCE_TESTING.md for quick testing reference
- Document changes in CHANGES_SUMMARY_TESTING_WORKFLOW.md
- Update README_CI_CD_SECTION.md with workflow details

## Other Changes:
- Update setup.py configuration
- Enhance task templates (create/edit/view) with improved UI

These changes improve developer experience with better testing documentation
and enhance user experience with smarter PWA prompts and dashboard functionality.
2025-10-22 07:28:39 +02:00

635 lines
42 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.
<!DOCTYPE html>
<html lang="{{ current_locale or 'en' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="description" content="Professional time tracking and project management">
<meta name="theme-color" content="#3b82f6">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='dist/output.css') }}">
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='form-bridge.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='enhanced-ui.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='toast-notifications.css') }}">
<style>
/* Minimal styles to properly align enhanced search UI */
.search-enhanced .search-input-wrapper { position: relative; }
.search-enhanced .search-icon { position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: #A0AEC0; pointer-events: none; }
.dark .search-enhanced .search-icon { color: #718096; }
.search-enhanced .search-actions { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); display: flex; align-items: center; gap: 0.25rem; }
.search-enhanced .search-actions .search-kbd { font-size: 11px; line-height: 1; padding: 2px 6px; border: 1px solid #E2E8F0; border-radius: 4px; color: #A0AEC0; }
.dark .search-enhanced .search-actions .search-kbd { border-color: #4A5568; color: #718096; }
/* Autocomplete dropdown: overlay below input */
.search-enhanced { position: relative; }
.search-enhanced .search-autocomplete { position: absolute; top: calc(100% + 4px); left: 0; right: 0; display: none; z-index: 50; background: #FFFFFF; color: #2D3748; border: 1px solid #E2E8F0; border-radius: 0.5rem; box-shadow: 0 8px 24px rgba(0,0,0,0.12); max-height: 22rem; overflow-y: auto; }
.dark .search-enhanced .search-autocomplete { background: #2D3748; color: #E2E8F0; border-color: #4A5568; }
.search-enhanced .search-autocomplete.show { display: block; }
.search-enhanced .search-section { padding: 0.5rem 0.5rem; }
.search-enhanced .search-section-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: .06em; margin: 0.25rem 0.5rem; color: #A0AEC0; }
.dark .search-enhanced .search-section-title { color: #718096; }
.search-enhanced .search-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.75rem; text-decoration: none; color: inherit; }
.search-enhanced .search-item:hover, .search-enhanced .search-item.keyboard-focus { background: #F7F9FB; }
.dark .search-enhanced .search-item:hover, .dark .search-enhanced .search-item.keyboard-focus { background: #1A202C; }
.search-enhanced .search-item-title { font-weight: 500; }
/* Sidebar collapsed helpers */
.sidebar-collapsed .sidebar-label { display: none; }
.sidebar-collapsed .sidebar-header-title { display: none; }
.sidebar-collapsed #workDropdown, .sidebar-collapsed #insightsDropdown { display: none; }
.sidebar-collapsed #sidebar { width: 4rem !important; overflow-x: hidden; }
</style>
<script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark')
}
</script>
{% block extra_css %}{% endblock %}
</head>
<body class="bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark">
<script>
// Apply collapsed sidebar state ASAP to prevent flicker on navigations
(function(){
try { if (localStorage.getItem('sidebar-collapsed') === 'true') { document.documentElement.classList.add('sidebar-collapsed'); } } catch(_) {}
})();
</script>
<a href="#mainContentAnchor" class="sr-only focus:not-sr-only focus-ring absolute left-2 top-2 z-[1000] px-3 py-2 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded">Skip to content</a>
<div id="appShell" class="flex h-screen">
<!-- Sidebar -->
<aside id="sidebar" class="w-64 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark p-4 flex-col hidden lg:flex transition-all duration-200 ease-in-out relative">
<div class="flex items-center mb-8">
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="Logo" class="h-10 w-10 mr-2">
<h1 class="text-2xl font-bold text-primary sidebar-header-title"><a href="{{ url_for('main.dashboard') }}" class="no-underline">TimeTracker</a></h1>
</div>
<nav class="flex-1">
{% set ep = request.endpoint or '' %}
{% set work_open = ep.startswith('projects.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') %}
{% set insights_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('analytics.') %}
<div class="flex items-center justify-between mb-4">
<h2 class="text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider sidebar-label">{{ _('Navigation') }}</h2>
<button id="sidebarCollapseBtn" class="p-1.5 rounded hover:bg-background-light dark:hover:bg-background-dark" aria-label="Toggle sidebar" title="Toggle sidebar">
<i id="sidebarCollapseIcon" class="fas fa-angles-left"></i>
</button>
</div>
<ul>
<li>
<a href="{{ url_for('main.dashboard') }}" class="flex items-center p-2 rounded-lg {% if ep == 'main.dashboard' %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-tachometer-alt w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Dashboard') }}</span>
</a>
</li>
<li class="mt-2">
<button onclick="toggleDropdown('workDropdown')" data-dropdown="workDropdown" class="w-full flex items-center p-2 rounded-lg {% if work_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-briefcase w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Work') }}</span>
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
</button>
<ul id="workDropdown" class="{% if not work_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
{% set nav_active_projects = ep.startswith('projects.') %}
{% set nav_active_clients = ep.startswith('clients.') %}
{% set nav_active_tasks = ep.startswith('tasks.') %}
{% set nav_active_kanban = ep.startswith('kanban.') %}
{% set nav_active_timer = ep.startswith('timer.') %}
<li>
<a class="block px-2 py-1 rounded {% if nav_active_projects %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('projects.list_projects') }}">{{ _('Projects') }}</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_clients %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('clients.list_clients') }}">{{ _('Clients') }}</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_tasks %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('tasks.list_tasks') }}">{{ _('Tasks') }}</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_kanban %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('kanban.board') }}">{{ _('Kanban') }}</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_timer %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('timer.manual_entry') }}">{{ _('Log Time') }}</a>
</li>
</ul>
</li>
<li class="mt-2">
<button onclick="toggleDropdown('insightsDropdown')" data-dropdown="insightsDropdown" class="w-full flex items-center p-2 rounded-lg {% if insights_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-chart-line w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Insights') }}</span>
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
</button>
<ul id="insightsDropdown" class="{% if not insights_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
{% set nav_active_reports = ep.startswith('reports.') %}
{% set nav_active_invoices = ep.startswith('invoices.') %}
{% set nav_active_analytics = ep.startswith('analytics.') %}
<li>
<a class="block px-2 py-1 rounded {% if nav_active_reports %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('reports.reports') }}">{{ _('Reports') }}</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_invoices %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('invoices.list_invoices') }}">{{ _('Invoices') }}</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_analytics %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('analytics.analytics_dashboard') }}">{{ _('Analytics') }}</a>
</li>
</ul>
</li>
{% if current_user.is_admin %}
<li class="mt-2">
<a href="{{ url_for('admin.admin_dashboard') }}" class="flex items-center p-2 rounded-lg {% if ep == 'admin.admin_dashboard' %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-cog w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Admin') }}</span>
</a>
</li>
<li class="mt-2">
<a href="{{ url_for('admin.oidc_debug') }}" class="flex items-center p-2 rounded-lg {% if ep == 'admin.oidc_debug' %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-shield-alt w-6 text-center"></i>
<span class="ml-3 sidebar-label">OIDC</span>
</a>
</li>
{% endif %}
</ul>
</nav>
<div class="mt-auto pt-4 border-t border-border-light dark:border-border-dark">
<ul>
<li>
<a href="{{ url_for('main.about') }}" class="flex items-center p-2 rounded-lg text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
<i class="fas fa-circle-info w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('About') }}</span>
</a>
</li>
<li class="mt-2">
<a href="{{ url_for('main.help') }}" class="flex items-center p-2 rounded-lg text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
<i class="fas fa-life-ring w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Help') }}</span>
</a>
</li>
<li class="mt-2">
<a href="https://buymeacoffee.com/DryTrix" target="_blank" rel="noopener noreferrer" class="flex items-center p-2 rounded-lg text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
<i class="fas fa-mug-saucer w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Buy me a coffee') }}</span>
</a>
</li>
</ul>
<!-- App Version -->
<div class="mt-4 pt-3 border-t border-border-light dark:border-border-dark">
<div class="flex items-center justify-center px-2 py-1 text-xs text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-code-branch w-4 text-center"></i>
<span class="ml-2 sidebar-label">v{{ app_version }}</span>
</div>
</div>
</div>
</aside>
<!-- Main content -->
<div id="mainContent" class="flex-1 flex flex-col transition-all duration-200 ease-in-out">
<!-- Header -->
<header class="bg-card-light dark:bg-card-dark p-4 border-b border-border-light dark:border-border-dark flex justify-between items-center">
<!-- Mobile menu button -->
<button id="mobileSidebarBtn" class="lg:hidden" aria-label="Open sidebar">
<i class="fas fa-bars"></i>
</button>
<!-- Search + Command button -->
<div class="hidden sm:flex items-center gap-2">
<div class="w-96">
<form class="navbar-search" role="search" action="{{ url_for('main.search') }}" method="get" autocomplete="off">
<label for="search" class="sr-only">{{ _('Search') }}</label>
<input id="search" name="q" type="search" placeholder="{{ _('Search') }}" aria-label="{{ _('Search') }}" data-enhanced-search='{"endpoint": "/api/search", "minChars": 2, "maxResults": 10}' class="w-full bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded-lg py-2 pl-10 pr-14 text-text-light dark:text-text-dark placeholder-text-muted-light dark:placeholder-text-muted-dark focus:outline-none focus:ring-2 focus:ring-primary" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
</form>
</div>
<button type="button" class="text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5" aria-label="Open command palette" onclick="window.openCommandPalette && window.openCommandPalette();">
<i class="fa-solid fa-terminal"></i>
</button>
</div>
<!-- Right side controls -->
<div class="flex items-center space-x-4">
<button id="theme-toggle" type="button" class="text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5" aria-label="{{ _('Toggle dark mode') }}">
<i id="theme-toggle-dark-icon" class="hidden fa-solid fa-moon w-5 h-5"></i>
<i id="theme-toggle-light-icon" class="hidden fa-solid fa-sun w-5 h-5"></i>
</button>
<!-- Language Switcher -->
<div class="relative z-50">
<button onclick="toggleDropdown('langDropdown')" class="flex items-center text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5" aria-haspopup="true" aria-expanded="false" aria-controls="langDropdown">
<i class="fas fa-globe"></i>
</button>
<ul id="langDropdown" class="hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg">
<li class="dropdown-header p-2 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Language') }}</li>
{% for code, label in config['LANGUAGES'].items() %}
<li>
<a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="{{ url_for('main.set_language') }}?lang={{ code }}">
{{ label }}
</a>
</li>
{% endfor %}
</ul>
</div>
<!-- User Profile -->
<div class="relative">
<button onclick="toggleDropdown('userDropdown')" class="flex items-center" aria-label="{{ _('User menu') }}" aria-haspopup="true" aria-expanded="false" aria-controls="userDropdown">
<img src="{{ url_for('static', filename='images/avatar-default.svg') }}" alt="{{ current_user.display_name }}" class="w-8 h-8 rounded-full">
<span class="ml-2 hidden md:inline">{{ current_user.display_name }}</span>
<i class="fas fa-chevron-down ml-1 hidden md:inline" aria-hidden="true"></i>
</button>
<ul id="userDropdown" class="hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg">
<li><a href="{{ url_for('auth.profile') }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">{{ _('Profile') }}</a></li>
<li><a href="{{ url_for('auth.logout') }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">{{ _('Logout') }}</a></li>
</ul>
</div>
</div>
</header>
<!-- Flash Messages (hidden, converted to toasts by toast-notifications.js) -->
<div id="flash-messages-container" class="hidden">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert {% if category == 'success' %}alert-success{% elif category == 'error' %}alert-danger{% elif category == 'warning' %}alert-warning{% else %}alert-info{% endif %}" data-toast-message="{{ message }}" data-toast-type="{{ category }}"></div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<!-- Main page content -->
<main id="mainContentAnchor" class="flex-1 p-6 overflow-y-auto">
{% block content %}{% endblock %}
</main>
</div>
<!-- Mobile Bottom Navigation -->
<nav class="lg:hidden fixed bottom-0 inset-x-0 bg-card-light dark:bg-card-dark border-t border-border-light dark:border-border-dark flex justify-around">
<a href="{{ url_for('main.dashboard') }}" class="flex flex-col items-center p-2 text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
<i class="fas fa-tachometer-alt"></i>
<span class="text-xs">{{ _('Dashboard') }}</span>
</a>
<a href="{{ url_for('projects.list_projects') }}" class="flex flex-col items-center p-2 text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
<i class="fas fa-briefcase"></i>
<span class="text-xs">{{ _('Projects') }}</span>
</a>
<a href="{{ url_for('timer.manual_entry') }}" class="flex flex-col items-center p-2 text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
<i class="fas fa-plus-circle text-2xl"></i>
</a>
<a href="{{ url_for('reports.reports') }}" class="flex flex-col items-center p-2 text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
<i class="fas fa-chart-line"></i>
<span class="text-xs">{{ _('Reports') }}</span>
</a>
<a href="{{ url_for('auth.profile') }}" class="flex flex-col items-center p-2 text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
<i class="fas fa-user-circle"></i>
<span class="text-xs">{{ _('Profile') }}</span>
</a>
</nav>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.1/anime.min.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='toast-notifications.css') }}">
<!-- Enhanced UI scripts -->
<script src="{{ url_for('static', filename='enhanced-search.js') }}"></script>
<script src="{{ url_for('static', filename='toast-notifications.js') }}"></script>
<script src="{{ url_for('static', filename='enhanced-tables.js') }}"></script>
<script src="{{ url_for('static', filename='interactions.js') }}"></script>
<!-- Old command palette and keyboard navigation (restored) -->
<script src="{{ url_for('static', filename='commands.js') }}"></script>
<script>
// Minimal global shortcuts: Ctrl+/ (focus search), Ctrl+Shift+L (toggle theme), 't' (toggle timer)
// Note: Ctrl+K is handled by keyboard-shortcuts-advanced.js for command palette
(function(){
function isTyping(e){
const t = e.target; const tag = (t && t.tagName || '').toLowerCase();
return tag === 'input' || tag === 'textarea' || tag === 'select' || (t && t.isContentEditable);
}
document.addEventListener('keydown', (e) => {
// Ctrl/Cmd + / -> focus search (removed Ctrl+K to avoid conflict with command palette)
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key === '/') {
e.preventDefault();
const search = document.getElementById('search');
if (search) {
search.focus();
if (search.select) search.select();
}
}
});
document.addEventListener('keydown', (e) => {
// Ctrl/Cmd + Shift + L -> toggle theme
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'l' || e.key === 'L')) {
e.preventDefault();
document.getElementById('theme-toggle')?.click();
}
});
async function toggleTimer(){
try {
const statusRes = await fetch("{{ url_for('timer.timer_status') }}", { credentials: 'same-origin' });
const status = await statusRes.json();
if (status && status.active) {
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
const stopRes = await fetch("{{ url_for('timer.stop_timer') }}", { method: 'POST', headers: { 'X-CSRF-Token': token }, credentials: 'same-origin' });
if (stopRes.ok) { window.toastManager && window.toastManager.info('Timer stopped'); }
window.location.href = "{{ url_for('main.dashboard') }}";
} else {
window.location.href = "{{ url_for('timer.manual_entry') }}";
}
} catch (_) { window.location.href = "{{ url_for('timer.manual_entry') }}"; }
}
document.addEventListener('keydown', (e) => {
if (isTyping(e)) return;
if (!e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 't' || e.key === 'T')) {
e.preventDefault();
toggleTimer();
}
});
})();
</script>
<script>
// Sidebar collapse logic with persisted state
(function(){
const appShell = document.getElementById('appShell');
const sidebar = document.getElementById('sidebar');
const main = document.getElementById('mainContent');
const collapseBtn = document.getElementById('sidebarCollapseBtn');
const mobileBtn = document.getElementById('mobileSidebarBtn');
let flyout;
function applyCollapsed(isCollapsed){
// Toggle a class on the shell to let Tailwind width utilities react
const icon = document.getElementById('sidebarCollapseIcon');
if (isCollapsed){
appShell.classList.add('sidebar-collapsed');
sidebar.classList.add('w-16');
sidebar.classList.remove('w-64');
icon && icon.classList.remove('fa-angles-left');
icon && icon.classList.add('fa-angles-right');
} else {
appShell.classList.remove('sidebar-collapsed');
sidebar.classList.remove('w-16');
sidebar.classList.add('w-64');
icon && icon.classList.remove('fa-angles-right');
icon && icon.classList.add('fa-angles-left');
}
}
// Initialize from storage
try {
const saved = localStorage.getItem('sidebar-collapsed');
applyCollapsed(saved === 'true');
} catch(_) {}
// Desktop toggle
collapseBtn && collapseBtn.addEventListener('click', function(){
const nowCollapsed = !appShell.classList.contains('sidebar-collapsed');
applyCollapsed(nowCollapsed);
try { localStorage.setItem('sidebar-collapsed', String(nowCollapsed)); } catch(_) {}
});
// Mobile toggle simply shows/hides sidebar via utility classes
mobileBtn && mobileBtn.addEventListener('click', function(){
// On small screens we can temporarily show sidebar as overlay
const showing = sidebar.classList.contains('hidden');
sidebar.classList.toggle('hidden', !showing);
sidebar.classList.toggle('fixed', showing);
sidebar.classList.toggle('inset-y-0', showing);
sidebar.classList.toggle('z-50', showing);
// Position the toggle icon correctly when overlayed
const iconBtn = document.getElementById('sidebarCollapseBtn');
if (iconBtn){ iconBtn.classList.toggle('right-3', showing); iconBtn.classList.toggle('-right-3', !showing); }
});
// Flyout submenu when collapsed
function hideFlyout(){
if (flyout){ flyout.classList.add('hidden'); flyout.innerHTML=''; }
}
function ensureFlyout(){
if (!flyout){
flyout = document.createElement('div');
flyout.id = 'sidebarFlyout';
flyout.className = 'hidden fixed z-50 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg p-2';
document.body.appendChild(flyout);
document.addEventListener('click', (e) => {
if (!flyout || flyout.classList.contains('hidden')) return;
const inside = flyout.contains(e.target);
const trigger = e.target.closest('[data-dropdown]');
if (!inside && !trigger) hideFlyout();
});
window.addEventListener('resize', hideFlyout);
window.addEventListener('scroll', hideFlyout, true);
}
}
function showFlyout(triggerBtn, listId){
ensureFlyout();
const list = document.getElementById(listId);
if (!list) return;
flyout.innerHTML = '';
const ul = document.createElement('ul');
ul.className = 'min-w-[12rem] space-y-1';
list.querySelectorAll('a').forEach((a) => {
const li = document.createElement('li');
const link = a.cloneNode(true);
link.classList.add('block','px-3','py-2','rounded','hover:bg-background-light','dark:hover:bg-background-dark');
li.appendChild(link);
ul.appendChild(li);
});
flyout.appendChild(ul);
const rect = triggerBtn.getBoundingClientRect();
const sbRect = sidebar.getBoundingClientRect();
const left = sbRect.right + 8 + window.scrollX;
const top = rect.top + window.scrollY;
flyout.style.left = left + 'px';
flyout.style.top = top + 'px';
flyout.classList.remove('hidden');
}
sidebar.addEventListener('click', function(e){
const btn = e.target.closest('[data-dropdown]');
if (!btn) return;
const listId = btn.getAttribute('data-dropdown');
const isCollapsed = appShell.classList.contains('sidebar-collapsed') || document.documentElement.classList.contains('sidebar-collapsed');
if (isCollapsed){
e.preventDefault();
e.stopPropagation();
showFlyout(btn, listId);
}
});
})();
</script>
<!-- Command Palette Modal (restored, Tailwind-styled, no Bootstrap required) -->
<div id="commandPaletteModal" class="fixed inset-0 z-50 hidden">
<div class="absolute inset-0 bg-black/50" onclick="document.getElementById('commandPaletteModal').classList.add('hidden')"></div>
<div class="relative max-w-2xl mx-auto mt-24 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-xl">
<div class="p-3 sm:p-4 border-b border-border-light dark:border-border-dark flex items-center justify-between">
<div id="commandPaletteHelp" class="text-xs text-text-muted-light dark:text-text-muted-dark">
Press <kbd class="px-1 py-0.5 bg-background-light dark:bg-background-dark border border-border-light dark:border-border-dark rounded text-xs">Esc</kbd> to close, <kbd class="px-1 py-0.5 bg-background-light dark:bg-background-dark border border-border-light dark:border-border-dark rounded text-xs">Shift+?</kbd> for all shortcuts
</div>
<button id="commandPaletteClose" class="px-2 py-1 text-sm hover:bg-background-light dark:hover:bg-background-dark rounded">Close</button>
</div>
<div class="p-3 sm:p-4">
<div class="flex items-center gap-2 mb-3">
<i class="fas fa-terminal text-text-muted-light dark:text-text-muted-dark"></i>
<input id="commandPaletteInput" type="text" class="w-full bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg py-2 px-3 focus:outline-none focus:ring-2 focus:ring-primary" placeholder="Type a command or search..." autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
</div>
<div id="commandPaletteList" class="flex flex-col max-h-96 overflow-y-auto divide-y divide-border-light dark:divide-border-dark"></div>
</div>
</div>
</div>
<script>
// Command Palette close handlers
(function() {
const modal = document.getElementById('commandPaletteModal');
const closeBtn = document.getElementById('commandPaletteClose');
const input = document.getElementById('commandPaletteInput');
if (closeBtn) {
closeBtn.addEventListener('click', () => modal.classList.add('hidden'));
}
// Close on Escape key
if (input) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
modal.classList.add('hidden');
}
});
}
})();
</script>
<script>
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
// Change the icons inside the button based on previous settings
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
themeToggleLightIcon.classList.remove('hidden');
} else {
themeToggleDarkIcon.classList.remove('hidden');
}
var themeToggleBtn = document.getElementById('theme-toggle');
themeToggleBtn.addEventListener('click', function() {
// toggle icons inside button
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
// if set via local storage previously
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
// if NOT set via local storage previously
} else {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
}
});
function toggleDropdown(id) {
const dropdown = document.getElementById(id);
dropdown.classList.toggle('hidden');
}
</script>
<!-- Enhanced UI Scripts -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="{{ url_for('static', filename='charts.js') }}"></script>
<script src="{{ url_for('static', filename='enhanced-ui.js') }}"></script>
<script src="{{ url_for('static', filename='onboarding.js') }}"></script>
<!-- Advanced Features -->
<script src="{{ url_for('static', filename='keyboard-shortcuts-advanced.js') }}"></script>
<script src="{{ url_for('static', filename='quick-actions.js') }}"></script>
<script src="{{ url_for('static', filename='smart-notifications.js') }}"></script>
<script src="{{ url_for('static', filename='dashboard-widgets.js') }}"></script>
<!-- PWA Registration -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/service-worker.js')
.then(registration => {
console.log('ServiceWorker registered:', registration);
// Check for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available
if (window.toastManager) {
const toast = window.toastManager.info('New version available!', 0);
const btn = document.createElement('button');
btn.textContent = 'Reload';
btn.className = 'ml-2 px-3 py-1 bg-primary text-white rounded hover:bg-primary/90';
btn.onclick = () => window.location.reload();
toast.appendChild(btn);
}
}
});
});
})
.catch(err => console.log('ServiceWorker registration failed:', err));
});
}
// Install prompt
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
// Check if user has previously dismissed the install prompt
const installPromptDismissed = localStorage.getItem('pwa-install-dismissed');
if (installPromptDismissed === 'true') {
return; // Don't show the prompt if it was dismissed before
}
// Show install button in UI
if (window.toastManager) {
const toast = window.toastManager.info('Install TimeTracker as an app!', 0);
const btn = document.createElement('button');
btn.textContent = 'Install';
btn.className = 'ml-2 px-3 py-1 bg-primary text-white rounded hover:bg-primary/90';
btn.onclick = async () => {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
window.toastManager.success('App installed successfully!');
localStorage.setItem('pwa-install-dismissed', 'true');
} else {
// User declined, remember their choice
localStorage.setItem('pwa-install-dismissed', 'true');
}
deferredPrompt = null;
toast.remove();
};
// Add a dismiss button
const dismissBtn = document.createElement('button');
dismissBtn.textContent = '×';
dismissBtn.className = 'ml-2 px-2 py-1 text-white hover:bg-white/20 rounded';
dismissBtn.title = 'Dismiss permanently';
dismissBtn.onclick = () => {
localStorage.setItem('pwa-install-dismissed', 'true');
toast.remove();
};
toast.appendChild(btn);
toast.appendChild(dismissBtn);
}
});
</script>
{% block scripts_extra %}{% endblock %}
</body>
</html>