feat: add transitions and micro-interactions across all views

- Page transitions with slide-up/fade on route changes (App.vue)
- NavRail sliding active indicator with spring-like easing
- List enter/leave/move animations on Entries, Projects, Clients, Timer
- Modal enter/leave transitions with scale+fade on all dialogs
- Dropdown transitions with overshoot on all select/picker components
- Button feedback (scale on hover/active), card hover lift effects
- Timer pulse on start, glow on stop, floating empty state icons
- Content fade-in on Dashboard, Reports, Calendar, Timesheet
- Tag chip enter/leave animations in AppTagInput
- Progress bar smooth width transitions
- Implementation plan document
This commit is contained in:
Your Name
2026-02-18 11:33:58 +02:00
parent c66e71f57d
commit a3a6ab2fdf
16 changed files with 2115 additions and 144 deletions

View File

@@ -34,7 +34,8 @@
</div>
<!-- Calendar Grid -->
<div class="flex-1 bg-bg-surface rounded-lg border border-border-subtle overflow-hidden flex flex-col min-h-0">
<Transition name="fade" mode="out-in">
<div :key="weekStart.getTime()" class="flex-1 bg-bg-surface rounded-lg border border-border-subtle overflow-hidden flex flex-col min-h-0">
<!-- Day column headers -->
<div class="grid shrink-0 border-b border-border-subtle" :style="gridStyle">
<!-- Top-left corner (hour gutter) -->
@@ -119,6 +120,7 @@
</div>
</div>
</div>
</Transition>
</div>
</template>

View File

@@ -12,11 +12,12 @@
</div>
<!-- Clients Grid -->
<div v-if="clientsStore.clients.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<TransitionGroup v-if="clientsStore.clients.length > 0" name="list" tag="div" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="client in clientsStore.clients"
v-for="(client, index) in clientsStore.clients"
:key="client.id"
class="group bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] hover:bg-bg-elevated transition-all duration-150 cursor-pointer"
class="group bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] card-hover cursor-pointer"
:style="{ transitionDelay: `${index * 30}ms` }"
@click="openEditDialog(client)"
>
<div class="p-4">
@@ -49,11 +50,11 @@
</div>
</div>
</div>
</div>
</TransitionGroup>
<!-- Empty State -->
<div v-else class="flex flex-col items-center justify-center py-16">
<Users class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
<Users class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" />
<p class="text-sm text-text-secondary mt-4">No clients yet</p>
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Clients let you organize projects and generate invoices with billing details.</p>
<button
@@ -65,12 +66,13 @@
</div>
<!-- Create/Edit Dialog -->
<Transition name="modal">
<div
v-if="showDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="closeDialog"
@click.self="tryCloseDialog"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6 animate-modal-enter max-h-[calc(100vh-2rem)] overflow-y-auto">
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
{{ editingClient ? 'Edit Client' : 'Create Client' }}
</h2>
@@ -201,14 +203,16 @@
</form>
</div>
</div>
</Transition>
<!-- Delete Confirmation Dialog -->
<Transition name="modal">
<div
v-if="showDeleteDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="cancelDelete"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6 animate-modal-enter">
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Client</h2>
<p class="text-[0.75rem] text-text-secondary mb-6">
Are you sure you want to delete "{{ clientToDelete?.name }}"? This action cannot be undone.
@@ -229,16 +233,31 @@
</div>
</div>
</div>
</Transition>
<AppDiscardDialog :show="showDiscardDialog" @cancel="cancelDiscard" @discard="confirmDiscard" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Users } from 'lucide-vue-next'
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
import { useClientsStore, type Client } from '../stores/clients'
import { useFormGuard } from '../utils/formGuard'
const clientsStore = useClientsStore()
const { showDiscardDialog, snapshot: snapshotForm, tryClose: tryCloseForm, confirmDiscard, cancelDiscard } = useFormGuard()
function getFormData() {
return { name: formData.name, email: formData.email, phone: formData.phone, address: formData.address, company: formData.company, tax_id: formData.tax_id, payment_terms: formData.payment_terms, notes: formData.notes }
}
function tryCloseDialog() {
tryCloseForm(getFormData(), closeDialog)
}
// Dialog state
const showDialog = ref(false)
const showDeleteDialog = ref(false)
@@ -275,6 +294,7 @@ function openCreateDialog() {
formData.payment_terms = undefined
formData.notes = undefined
billingOpen.value = false
snapshotForm(getFormData())
showDialog.value = true
}
@@ -291,6 +311,7 @@ function openEditDialog(client: Client) {
formData.payment_terms = client.payment_terms
formData.notes = client.notes
billingOpen.value = hasBillingData(client)
snapshotForm(getFormData())
showDialog.value = true
}

View File

@@ -2,7 +2,7 @@
<div class="p-6">
<!-- Empty state -->
<div v-if="isEmpty" class="flex flex-col items-center justify-center py-16">
<Clock class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
<Clock class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" />
<p class="text-sm text-text-secondary mt-4">Start tracking your time</p>
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Your dashboard will come alive with stats, charts, and recent activity once you start logging hours.</p>
<router-link to="/timer" class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
@@ -11,7 +11,8 @@
</div>
<!-- Main content -->
<template v-else>
<Transition name="fade" appear>
<div v-if="!isEmpty">
<!-- Greeting header -->
<div class="mb-8">
<p class="text-xl font-[family-name:var(--font-heading)] text-text-secondary">{{ greeting }}</p>
@@ -49,7 +50,7 @@
</div>
<div class="w-full bg-bg-elevated rounded-full h-1.5">
<div
class="h-1.5 rounded-full bg-accent transition-all"
class="h-1.5 rounded-full bg-accent progress-bar"
:style="{ width: Math.min(dailyPct, 100) + '%' }"
/>
</div>
@@ -61,7 +62,7 @@
</div>
<div class="w-full bg-bg-elevated rounded-full h-1.5">
<div
class="h-1.5 rounded-full bg-accent transition-all"
class="h-1.5 rounded-full bg-accent progress-bar"
:style="{ width: Math.min(weeklyPct, 100) + '%' }"
/>
</div>
@@ -130,7 +131,8 @@
</div>
</div>
</div>
</template>
</div>
</Transition>
</div>
</template>

View File

@@ -70,11 +70,12 @@
<th class="px-4 py-3 w-20"></th>
</tr>
</thead>
<tbody>
<TransitionGroup name="list" tag="tbody">
<tr
v-for="entry in filteredEntries"
v-for="(entry, index) in filteredEntries"
:key="entry.id"
class="group border-b border-border-subtle hover:bg-bg-elevated transition-colors duration-150"
:style="{ transitionDelay: `${index * 30}ms` }"
>
<td class="px-4 py-3 text-[0.75rem] text-text-primary">
{{ formatDate(entry.start_time) }}
@@ -138,12 +139,12 @@
</div>
</td>
</tr>
</tbody>
</TransitionGroup>
</table>
</div>
<div v-else class="flex flex-col items-center justify-center py-16">
<ListIcon class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
<ListIcon class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" />
<p class="text-sm text-text-secondary mt-4">No entries found</p>
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Time entries will appear here as you track your work. Try adjusting the date range if you have existing entries.</p>
<router-link to="/timer" class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
@@ -152,12 +153,13 @@
</div>
<!-- Edit Dialog -->
<Transition name="modal">
<div
v-if="showEditDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="tryCloseEditDialog"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6 animate-modal-enter max-h-[calc(100vh-2rem)] overflow-y-auto">
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Edit Entry</h2>
<form @submit.prevent="handleEdit" class="space-y-4">
@@ -232,14 +234,16 @@
</form>
</div>
</div>
</Transition>
<!-- Delete Confirmation Dialog -->
<Transition name="modal">
<div
v-if="showDeleteDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="cancelDelete"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6 animate-modal-enter">
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Entry</h2>
<p class="text-[0.75rem] text-text-secondary mb-6">
Are you sure you want to delete this time entry? This action cannot be undone.
@@ -260,6 +264,7 @@
</div>
</div>
</div>
</Transition>
<AppDiscardDialog :show="showDiscardDialog" @cancel="cancelDiscard" @discard="confirmDiscard" />
</div>

View File

@@ -12,11 +12,12 @@
</div>
<!-- Projects Grid -->
<div v-if="projectsStore.projects.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<TransitionGroup v-if="projectsStore.projects.length > 0" name="list" tag="div" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="project in projectsStore.projects"
v-for="(project, index) in projectsStore.projects"
:key="project.id"
class="group bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] hover:bg-bg-elevated transition-all duration-150 cursor-pointer"
class="group bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] card-hover cursor-pointer"
:style="{ transitionDelay: `${index * 30}ms` }"
@click="openEditDialog(project)"
>
<div class="flex border-l-[2px] hover:border-l-[3px] rounded-l-lg transition-all duration-150" :style="{ borderLeftColor: project.color }">
@@ -54,7 +55,7 @@
</div>
<div class="w-full bg-bg-elevated rounded-full h-1">
<div
class="h-1 rounded-full transition-all"
class="h-1 rounded-full progress-bar"
:class="getBudgetPct(project) > 90 ? 'bg-status-error' : getBudgetPct(project) > 75 ? 'bg-status-warning' : 'bg-accent'"
:style="{ width: Math.min(getBudgetPct(project), 100) + '%' }"
/>
@@ -63,11 +64,11 @@
</div>
</div>
</div>
</div>
</TransitionGroup>
<!-- Empty State -->
<div v-else class="flex flex-col items-center justify-center py-16">
<FolderKanban class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
<FolderKanban class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" />
<p class="text-sm text-text-secondary mt-4">No projects yet</p>
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Projects organize your time entries and set billing rates for clients.</p>
<button
@@ -79,12 +80,13 @@
</div>
<!-- Create/Edit Dialog -->
<Transition name="modal">
<div
v-if="showDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="tryCloseDialog"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6 animate-modal-enter max-h-[calc(100vh-2rem)] overflow-y-auto">
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
{{ editingProject ? 'Edit Project' : 'Create Project' }}
</h2>
@@ -240,14 +242,16 @@
</form>
</div>
</div>
</Transition>
<!-- Delete Confirmation Dialog -->
<Transition name="modal">
<div
v-if="showDeleteDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="cancelDelete"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6 animate-modal-enter">
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Project</h2>
<p class="text-[0.75rem] text-text-secondary mb-6">
Are you sure you want to delete "{{ projectToDelete?.name }}"? This action cannot be undone.
@@ -268,6 +272,7 @@
</div>
</div>
</div>
</Transition>
<AppDiscardDialog :show="showDiscardDialog" @cancel="cancelDiscard" @discard="confirmDiscard" />
</div>

View File

@@ -1,4 +1,5 @@
<template>
<Transition name="fade" appear>
<div class="p-6">
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-6">Reports</h1>
@@ -198,6 +199,7 @@
</div>
</template>
</div>
</Transition>
</template>
<script setup lang="ts">

View File

@@ -3,7 +3,7 @@
<!-- Hero timer display -->
<div class="text-center pt-4 pb-8">
<div class="relative inline-block">
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2">
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2" :class="timerPulseClass">
<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>
@@ -32,7 +32,7 @@
<button
@click="toggleTimer"
class="px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
class="btn-primary px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
:class="buttonClass"
>
{{ buttonLabel }}
@@ -41,19 +41,20 @@
<!-- Favorites strip -->
<div v-if="favorites.length > 0" class="max-w-[36rem] mx-auto mb-4">
<div class="flex items-center gap-2 overflow-x-auto pb-1">
<TransitionGroup tag="div" name="chip" class="flex items-center gap-2 overflow-x-auto pb-1">
<button
v-for="fav in favorites"
v-for="(fav, favIndex) in favorites"
:key="fav.id"
@click="applyFavorite(fav)"
:disabled="!timerStore.isStopped"
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border-subtle text-[0.6875rem] text-text-secondary hover:text-text-primary hover:border-border-visible transition-colors disabled:opacity-40"
:style="{ transitionDelay: `${favIndex * 50}ms` }"
>
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: getProjectColor(fav.project_id) }" />
{{ getProjectName(fav.project_id) }}
<span v-if="fav.description" class="text-text-tertiary">&middot; {{ fav.description }}</span>
</button>
</div>
</TransitionGroup>
</div>
<!-- Inputs -->
@@ -117,12 +118,13 @@
<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">
<TransitionGroup v-if="recentEntries.length > 0" name="list" tag="div">
<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' : ''"
:style="{ transitionDelay: `${index * 40}ms` }"
>
<div class="flex items-center gap-3">
<div
@@ -149,11 +151,11 @@
</button>
</div>
</div>
</div>
</TransitionGroup>
<!-- 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" />
<TimerIcon class="w-10 h-10 text-text-tertiary animate-float" :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>
@@ -209,6 +211,7 @@ const selectedTask = ref<number | null>(timerStore.selectedTaskId)
const description = ref(timerStore.description)
const selectedTags = ref<number[]>([])
const projectTasks = ref<Task[]>([])
const timerPulseClass = ref('')
// Split timer into parts for colon animation
const timerParts = computed(() => {
@@ -243,6 +246,21 @@ const buttonClass = computed(() => {
return 'bg-status-error text-white hover:bg-status-error/80'
})
// Timer start/stop pulse animation
watch(() => timerStore.isRunning, (isRunning, wasRunning) => {
if (isRunning && !wasRunning) {
timerPulseClass.value = 'animate-timer-pulse'
setTimeout(() => { timerPulseClass.value = '' }, 300)
}
})
watch(() => timerStore.isStopped, (isStopped, wasStopped) => {
if (isStopped && !wasStopped) {
timerPulseClass.value = 'animate-timer-glow'
setTimeout(() => { timerPulseClass.value = '' }, 600)
}
})
// Watch project selection and fetch tasks
watch(selectedProject, async (newProjectId) => {
timerStore.setProject(newProjectId)

View File

@@ -35,7 +35,8 @@
</div>
<!-- Timesheet Table -->
<div class="bg-bg-surface rounded-lg overflow-hidden">
<Transition name="fade" mode="out-in">
<div :key="weekStart" class="bg-bg-surface rounded-lg overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-border-subtle">
@@ -156,6 +157,7 @@
</tfoot>
</table>
</div>
</Transition>
<!-- Add Row Button -->
<button