Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Minimal Time Tracker</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet"> | |
| <script src="https://unpkg.com/aos@2.3.1/dist/aos.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script> | |
| <script src="https://unpkg.com/feather-icons"></script> | |
| <style> | |
| .timer-card { | |
| transition: all 0.3s ease; | |
| } | |
| .timer-card:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1); | |
| } | |
| .time-display { | |
| font-family: 'SF Mono', monospace; | |
| font-size: 3rem; | |
| font-weight: 300; | |
| display: flex; | |
| gap: 0.25rem; | |
| } | |
| .time-segment { | |
| background: #f3f4f6; | |
| border-radius: 0.5rem; | |
| padding: 0.5rem 0.75rem; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.05); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .time-segment::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| height: 50%; | |
| background: rgba(255,255,255,0.2); | |
| border-bottom: 1px solid rgba(0,0,0,0.05); | |
| } | |
| .sheet-row { | |
| border-bottom: 1px solid #e5e7eb; | |
| } | |
| .sheet-row:last-child { | |
| border-bottom: none; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen"> | |
| <div class="container mx-auto px-4 py-8 max-w-6xl"> | |
| <!-- Header --> | |
| <header class="mb-12"> | |
| <h1 class="text-4xl font-light text-gray-900 text-center mb-2">Time Tracker</h1> | |
| <p class="text-gray-500 text-center max-w-lg mx-auto">Track time across multiple projects with minimal distraction</p> | |
| </header> | |
| <!-- Timer Creation Form --> | |
| <div class="bg-white rounded-xl shadow-sm p-6 mb-8" data-aos="fade-up"> | |
| <h2 class="text-xl font-medium text-gray-800 mb-4">Create New Timer</h2> | |
| <form id="timer-form" class="grid grid-cols-1 md:grid-cols-4 gap-4"> | |
| <div> | |
| <label for="client" class="block text-sm font-medium text-gray-700 mb-1">Client</label> | |
| <input type="text" id="client" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| </div> | |
| <div> | |
| <label for="project" class="block text-sm font-medium text-gray-700 mb-1">Project</label> | |
| <input type="text" id="project" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| </div> | |
| <div> | |
| <label for="task" class="block text-sm font-medium text-gray-700 mb-1">Task</label> | |
| <input type="text" id="task" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| </div> | |
| <div class="flex items-end"> | |
| <button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md transition duration-150 ease-in-out flex items-center justify-center"> | |
| <i data-feather="plus" class="mr-2"></i> Start Timer | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| <!-- Active Timers Section --> | |
| <section class="mb-12"> | |
| <h2 class="text-xl font-medium text-gray-800 mb-4">Active Timers</h2> | |
| <div id="active-timers" class="grid grid-cols-1 gap-4"> | |
| <!-- Timers will be added here dynamically --> | |
| </div> | |
| </section> | |
| <!-- Time Sheet Section --> | |
| <section> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h2 class="text-xl font-medium text-gray-800">Time Sheet</h2> | |
| <div class="flex space-x-2"> | |
| <button id="clear-sheet" class="text-sm text-gray-500 hover:text-gray-700 flex items-center"> | |
| <i data-feather="trash-2" class="w-4 h-4 mr-1"></i> Clear All | |
| </button> | |
| <button id="daily-close" class="text-sm text-red-500 hover:text-red-700 flex items-center"> | |
| <i data-feather="file-text" class="w-4 h-4 mr-1"></i> Daily Closing | |
| </button> | |
| </div> | |
| </div> | |
| <div class="bg-white rounded-xl shadow-sm overflow-hidden"> | |
| <div class="grid grid-cols-12 bg-gray-50 p-4 border-b border-gray-200"> | |
| <div class="col-span-3 text-sm font-medium text-gray-500">Client</div> | |
| <div class="col-span-3 text-sm font-medium text-gray-500">Project</div> | |
| <div class="col-span-3 text-sm font-medium text-gray-500">Task</div> | |
| <div class="col-span-2 text-sm font-medium text-gray-500">Duration</div> | |
| <div class="col-span-1 text-sm font-medium text-gray-500">Date</div> | |
| </div> | |
| <div id="time-sheet" class="divide-y divide-gray-200"> | |
| <!-- Time entries will be added here dynamically --> | |
| <div class="grid grid-cols-12 p-4 items-center sheet-row"> | |
| <div class="col-span-3 text-gray-700">Example Client</div> | |
| <div class="col-span-3 text-gray-700">Website Redesign</div> | |
| <div class="col-span-3 text-gray-700">Homepage Layout</div> | |
| <div class="col-span-2 text-gray-700">01:23:45</div> | |
| <div class="col-span-1 text-sm text-gray-500">Today</div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', function() { | |
| AOS.init(); | |
| feather.replace(); | |
| const timerForm = document.getElementById('timer-form'); | |
| const activeTimersContainer = document.getElementById('active-timers'); | |
| const timeSheetContainer = document.getElementById('time-sheet'); | |
| const clearSheetBtn = document.getElementById('clear-sheet'); | |
| let timers = []; | |
| let timeEntries = JSON.parse(localStorage.getItem('timeEntries')) || []; | |
| // Render initial time sheet if entries exist | |
| if (timeEntries.length > 0) { | |
| renderTimeSheet(); | |
| } else { | |
| // Remove example entry if no saved entries | |
| timeSheetContainer.innerHTML = ''; | |
| } | |
| timerForm.addEventListener('submit', function(e) { | |
| e.preventDefault(); | |
| const client = document.getElementById('client').value; | |
| const project = document.getElementById('project').value; | |
| const task = document.getElementById('task').value; | |
| if (!client || !project || !task) return; | |
| const timerId = Date.now().toString(); | |
| const newTimer = { | |
| id: timerId, | |
| client, | |
| project, | |
| task, | |
| startTime: new Date(), | |
| isRunning: true, | |
| elapsed: 0 | |
| }; | |
| timers.push(newTimer); | |
| renderActiveTimers(); | |
| // Clear form | |
| timerForm.reset(); | |
| }); | |
| document.getElementById('daily-close').addEventListener('click', generateDailyReport); | |
| clearSheetBtn.addEventListener('click', function() { | |
| if (timeEntries.length === 0) return; | |
| if (confirm('Are you sure you want to clear all time entries?')) { | |
| timeEntries = []; | |
| localStorage.setItem('timeEntries', JSON.stringify(timeEntries)); | |
| timeSheetContainer.innerHTML = ''; | |
| } | |
| }); | |
| function renderActiveTimers() { | |
| activeTimersContainer.innerHTML = ''; | |
| timers.forEach(timer => { | |
| const timerCard = document.createElement('div'); | |
| timerCard.className = 'timer-card bg-white rounded-xl shadow-sm p-6 flex flex-col md:flex-row md:items-center justify-between'; | |
| const timerInfo = document.createElement('div'); | |
| timerInfo.className = 'mb-4 md:mb-0'; | |
| const timerTitle = document.createElement('h3'); | |
| timerTitle.className = 'text-lg font-medium text-gray-800'; | |
| timerTitle.textContent = `${timer.client} / ${timer.project} / ${timer.task}`; | |
| const timerTime = document.createElement('div'); | |
| timerTime.className = 'time-display text-gray-700 mt-2'; | |
| const formattedTime = formatTime(timer.elapsed).split(':'); | |
| ['hours', 'minutes', 'seconds'].forEach((unit, index) => { | |
| const segment = document.createElement('div'); | |
| segment.className = 'flex flex-col items-center'; | |
| const label = document.createElement('div'); | |
| label.className = 'text-xs text-gray-500 uppercase mt-1'; | |
| label.textContent = unit; | |
| const value = document.createElement('div'); | |
| value.className = 'time-segment'; | |
| value.textContent = formattedTime[index]; | |
| segment.appendChild(value); | |
| segment.appendChild(label); | |
| timerTime.appendChild(segment); | |
| }); | |
| timerInfo.appendChild(timerTitle); | |
| timerInfo.appendChild(timerTime); | |
| const timerActions = document.createElement('div'); | |
| timerActions.className = 'flex space-x-2'; | |
| const pauseBtn = document.createElement('button'); | |
| pauseBtn.className = `px-4 py-2 rounded-md flex items-center ${timer.isRunning ? 'bg-yellow-500 hover:bg-yellow-600' : 'bg-blue-600 hover:bg-blue-700'} text-white transition duration-150 ease-in-out`; | |
| pauseBtn.innerHTML = `<i data-feather="${timer.isRunning ? 'pause' : 'play'}" class="mr-2"></i> ${timer.isRunning ? 'Pause' : 'Resume'}`; | |
| const stopBtn = document.createElement('button'); | |
| stopBtn.className = 'px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 transition duration-150 ease-in-out flex items-center'; | |
| stopBtn.innerHTML = '<i data-feather="square" class="mr-2"></i> Stop'; | |
| timerActions.appendChild(pauseBtn); | |
| timerActions.appendChild(stopBtn); | |
| timerCard.appendChild(timerInfo); | |
| timerCard.appendChild(timerActions); | |
| activeTimersContainer.appendChild(timerCard); | |
| // Add event listeners | |
| pauseBtn.addEventListener('click', () => toggleTimer(timer.id)); | |
| stopBtn.addEventListener('click', () => stopTimer(timer.id)); | |
| // Start interval for running timers | |
| if (timer.isRunning) { | |
| const intervalId = setInterval(() => { | |
| timer.elapsed += 1; | |
| timerTime.textContent = formatTime(timer.elapsed); | |
| }, 1000); | |
| timer.intervalId = intervalId; | |
| } | |
| feather.replace(); | |
| }); | |
| } | |
| function toggleTimer(timerId) { | |
| const timer = timers.find(t => t.id === timerId); | |
| if (!timer) return; | |
| timer.isRunning = !timer.isRunning; | |
| if (timer.isRunning) { | |
| const intervalId = setInterval(() => { | |
| timer.elapsed += 1; | |
| const timerElement = document.querySelector(`[data-timer-id="${timerId}"]`); | |
| if (timerElement) { | |
| const formattedTime = formatTime(timer.elapsed).split(':'); | |
| const segments = timerElement.parentElement.querySelectorAll('.time-segment'); | |
| segments.forEach((segment, index) => { | |
| segment.textContent = formattedTime[index]; | |
| }); | |
| } | |
| }, 1000); | |
| timer.intervalId = intervalId; | |
| } else { | |
| clearInterval(timer.intervalId); | |
| } | |
| renderActiveTimers(); | |
| } | |
| function stopTimer(timerId) { | |
| const timerIndex = timers.findIndex(t => t.id === timerId); | |
| if (timerIndex === -1) return; | |
| const timer = timers[timerIndex]; | |
| // Clear interval | |
| if (timer.intervalId) { | |
| clearInterval(timer.intervalId); | |
| } | |
| // Add to time sheet | |
| const now = new Date(); | |
| const dateString = now.toLocaleDateString(); | |
| const timeEntry = { | |
| client: timer.client, | |
| project: timer.project, | |
| task: timer.task, | |
| duration: timer.elapsed, | |
| date: dateString, | |
| timestamp: now.getTime() | |
| }; | |
| timeEntries.push(timeEntry); | |
| localStorage.setItem('timeEntries', JSON.stringify(timeEntries)); | |
| // Remove from active timers | |
| timers.splice(timerIndex, 1); | |
| renderActiveTimers(); | |
| renderTimeSheet(); | |
| } | |
| function generateDailyReport() { | |
| if (timeEntries.length === 0) { | |
| alert('No time entries to generate report'); | |
| return; | |
| } | |
| // Group by client | |
| const clients = {}; | |
| timeEntries.forEach(entry => { | |
| const clientKey = entry.client.substring(0, 3).toUpperCase(); | |
| if (!clients[clientKey]) { | |
| clients[clientKey] = []; | |
| } | |
| clients[clientKey].push(entry); | |
| }); | |
| // Generate report text | |
| let reportText = ''; | |
| let grandTotalSeconds = 0; | |
| Object.entries(clients).forEach(([clientKey, entries]) => { | |
| reportText += `${clientKey}\n-----\n`; | |
| let clientTotalSeconds = 0; | |
| entries.forEach(entry => { | |
| reportText += `${entry.client} / ${entry.project} / ${entry.task} / ${formatTime(entry.duration)} / ${entry.date}\n`; | |
| clientTotalSeconds += entry.duration; | |
| }); | |
| grandTotalSeconds += clientTotalSeconds; | |
| reportText += `_____\nTotal Time: ${formatTime(clientTotalSeconds)}\n-----\n`; | |
| }); | |
| reportText += `\nGRAND TOTAL: ${formatTime(grandTotalSeconds)}`; | |
| // Create download | |
| const blob = new Blob([reportText], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `time_report_${new Date().toISOString().split('T')[0]}.txt`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| function renderTimeSheet() { | |
| timeSheetContainer.innerHTML = ''; | |
| timeEntries.sort((a, b) => b.timestamp - a.timestamp).forEach(entry => { | |
| const entryRow = document.createElement('div'); | |
| entryRow.className = 'grid grid-cols-12 p-4 items-center sheet-row'; | |
| const clientCell = document.createElement('div'); | |
| clientCell.className = 'col-span-3 text-gray-700'; | |
| clientCell.textContent = entry.client; | |
| const projectCell = document.createElement('div'); | |
| projectCell.className = 'col-span-3 text-gray-700'; | |
| projectCell.textContent = entry.project; | |
| const taskCell = document.createElement('div'); | |
| taskCell.className = 'col-span-3 text-gray-700'; | |
| taskCell.textContent = entry.task; | |
| const durationCell = document.createElement('div'); | |
| durationCell.className = 'col-span-2 text-gray-700'; | |
| durationCell.textContent = formatTime(entry.duration); | |
| const dateCell = document.createElement('div'); | |
| dateCell.className = 'col-span-1 text-sm text-gray-500'; | |
| dateCell.textContent = entry.date === new Date().toLocaleDateString() ? 'Today' : entry.date; | |
| entryRow.appendChild(clientCell); | |
| entryRow.appendChild(projectCell); | |
| entryRow.appendChild(taskCell); | |
| entryRow.appendChild(durationCell); | |
| entryRow.appendChild(dateCell); | |
| timeSheetContainer.appendChild(entryRow); | |
| }); | |
| } | |
| function formatTime(seconds) { | |
| const hrs = Math.floor(seconds / 3600); | |
| const mins = Math.floor((seconds % 3600) / 60); | |
| const secs = seconds % 60; | |
| return [ | |
| hrs.toString().padStart(2, '0'), | |
| mins.toString().padStart(2, '0'), | |
| secs.toString().padStart(2, '0') | |
| ].join(':'); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |