feat: redesign Timer - amber Start, colon pulse, toast
This commit is contained in:
235
src/views/Timer.vue
Normal file
235
src/views/Timer.vue
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Hero timer display -->
|
||||||
|
<div class="text-center pt-4 pb-8">
|
||||||
|
<p class="text-[3rem] font-mono font-medium tracking-wider mb-6">
|
||||||
|
<span class="text-text-primary">{{ timerParts.hours }}</span>
|
||||||
|
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
|
||||||
|
<span class="text-text-primary">{{ timerParts.minutes }}</span>
|
||||||
|
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
|
||||||
|
<span class="text-text-primary">{{ timerParts.seconds }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="toggleTimer"
|
||||||
|
class="px-10 py-3 text-sm font-medium rounded transition-colors duration-150"
|
||||||
|
:class="timerStore.isRunning
|
||||||
|
? 'bg-status-error text-white hover:bg-status-error/80'
|
||||||
|
: 'bg-accent text-bg-base hover:bg-accent-hover'"
|
||||||
|
>
|
||||||
|
{{ timerStore.isRunning ? 'Stop' : 'Start' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inputs -->
|
||||||
|
<div class="max-w-[36rem] mx-auto mb-8">
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Project</label>
|
||||||
|
<select
|
||||||
|
v-model="selectedProject"
|
||||||
|
:disabled="timerStore.isRunning"
|
||||||
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<option :value="null">Select project</option>
|
||||||
|
<option
|
||||||
|
v-for="project in activeProjects"
|
||||||
|
:key="project.id"
|
||||||
|
:value="project.id"
|
||||||
|
>
|
||||||
|
{{ project.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Task</label>
|
||||||
|
<select
|
||||||
|
v-model="selectedTask"
|
||||||
|
:disabled="timerStore.isRunning || !selectedProject"
|
||||||
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<option :value="null">Select task</option>
|
||||||
|
<option
|
||||||
|
v-for="task in projectTasks"
|
||||||
|
:key="task.id"
|
||||||
|
:value="task.id"
|
||||||
|
>
|
||||||
|
{{ task.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description</label>
|
||||||
|
<input
|
||||||
|
v-model="description"
|
||||||
|
type="text"
|
||||||
|
:disabled="timerStore.isRunning"
|
||||||
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded text-[0.8125rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
placeholder="What are you working on?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent entries -->
|
||||||
|
<div class="max-w-[36rem] mx-auto">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em]">Recent</h2>
|
||||||
|
<router-link v-if="recentEntries.length > 0" to="/entries" class="text-xs text-text-tertiary hover:text-text-secondary transition-colors">View all</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="recentEntries.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="(entry, index) in recentEntries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="flex items-center justify-between py-3 border-b border-border-subtle last:border-0"
|
||||||
|
:class="index === 0 ? 'border-l-2 border-l-accent pl-3' : ''"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-2 h-2 rounded-full shrink-0"
|
||||||
|
:style="{ backgroundColor: getProjectColor(entry.project_id) }"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.75rem] text-text-primary">{{ getProjectName(entry.project_id) }}</p>
|
||||||
|
<p class="text-[0.6875rem] text-text-tertiary">{{ entry.description || 'No description' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-[0.75rem] font-mono text-text-primary">{{ formatDuration(entry.duration) }}</p>
|
||||||
|
<p class="text-[0.6875rem] text-text-tertiary">{{ formatDate(entry.start_time) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else class="flex flex-col items-center py-8">
|
||||||
|
<TimerIcon class="w-10 h-10 text-text-tertiary" :stroke-width="1.5" />
|
||||||
|
<p class="text-sm text-text-secondary mt-3">No entries today</p>
|
||||||
|
<p class="text-xs text-text-tertiary mt-1">Select a project and hit Start to begin tracking</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { useTimerStore } from '../stores/timer'
|
||||||
|
import { useProjectsStore, type Task } from '../stores/projects'
|
||||||
|
import { useEntriesStore } from '../stores/entries'
|
||||||
|
import { useToastStore } from '../stores/toast'
|
||||||
|
import { Timer as TimerIcon } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const timerStore = useTimerStore()
|
||||||
|
const projectsStore = useProjectsStore()
|
||||||
|
const entriesStore = useEntriesStore()
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
|
||||||
|
// Local state for inputs
|
||||||
|
const selectedProject = ref<number | null>(timerStore.selectedProjectId)
|
||||||
|
const selectedTask = ref<number | null>(timerStore.selectedTaskId)
|
||||||
|
const description = ref(timerStore.description)
|
||||||
|
const projectTasks = ref<Task[]>([])
|
||||||
|
|
||||||
|
// Split timer into parts for colon animation
|
||||||
|
const timerParts = computed(() => {
|
||||||
|
const time = timerStore.formattedTime
|
||||||
|
const parts = time.split(':')
|
||||||
|
return {
|
||||||
|
hours: parts[0] || '00',
|
||||||
|
minutes: parts[1] || '00',
|
||||||
|
seconds: parts[2] || '00'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Active (non-archived) projects
|
||||||
|
const activeProjects = computed(() => {
|
||||||
|
return projectsStore.projects.filter(p => !p.archived)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recent entries (last 5)
|
||||||
|
const recentEntries = computed(() => {
|
||||||
|
return entriesStore.entries.slice(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch project selection and fetch tasks
|
||||||
|
watch(selectedProject, async (newProjectId) => {
|
||||||
|
timerStore.setProject(newProjectId)
|
||||||
|
selectedTask.value = null
|
||||||
|
projectTasks.value = []
|
||||||
|
|
||||||
|
if (newProjectId) {
|
||||||
|
projectTasks.value = await projectsStore.fetchTasks(newProjectId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch task selection
|
||||||
|
watch(selectedTask, (newTaskId) => {
|
||||||
|
timerStore.setTask(newTaskId)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch description
|
||||||
|
watch(description, (newDesc) => {
|
||||||
|
timerStore.setDescription(newDesc)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Toggle timer
|
||||||
|
function toggleTimer() {
|
||||||
|
if (timerStore.isRunning) {
|
||||||
|
timerStore.stop()
|
||||||
|
entriesStore.fetchEntries()
|
||||||
|
} else {
|
||||||
|
if (!selectedProject.value) {
|
||||||
|
toastStore.info('Please select a project before starting the timer')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timerStore.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project name by ID
|
||||||
|
function getProjectName(projectId: number): string {
|
||||||
|
const project = projectsStore.projects.find(p => p.id === projectId)
|
||||||
|
return project?.name || 'Unknown Project'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project color by ID
|
||||||
|
function getProjectColor(projectId: number): string {
|
||||||
|
const project = projectsStore.projects.find(p => p.id === projectId)
|
||||||
|
return project?.color || '#6B7280'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format duration from seconds to readable format
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`
|
||||||
|
}
|
||||||
|
return `${minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load data on mount
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
projectsStore.fetchProjects(),
|
||||||
|
entriesStore.fetchEntries()
|
||||||
|
])
|
||||||
|
|
||||||
|
// Restore timer state
|
||||||
|
selectedProject.value = timerStore.selectedProjectId
|
||||||
|
selectedTask.value = timerStore.selectedTaskId
|
||||||
|
description.value = timerStore.description
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user