Files
zeroclock/src/views/Dashboard.vue
Your Name 87b1853f39 feat: add budget progress indicators to Projects and Dashboard
Project edit dialog includes budget hours and amount fields. Project
cards show progress bars with color-coded status. Dashboard displays
budget alerts section for projects exceeding 75% of budget.
2026-02-18 10:51:47 +02:00

396 lines
14 KiB
Vue

<template>
<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" />
<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">
Go to Timer
</router-link>
</div>
<!-- Main content -->
<template v-else>
<!-- Greeting header -->
<div class="mb-8">
<p class="text-xl font-[family-name:var(--font-heading)] text-text-secondary">{{ greeting }}</p>
<p class="text-xs text-text-tertiary mt-1">{{ formattedDate }}</p>
</div>
<!-- Stats row 4 columns -->
<div class="grid grid-cols-4 gap-6 mb-8">
<div>
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Today</p>
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(todayStats.totalSeconds) }}</p>
</div>
<div>
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">This Week</p>
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(weekStats.totalSeconds) }}</p>
</div>
<div>
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">This Month</p>
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(monthStats.totalSeconds) }}</p>
</div>
<div>
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Active Projects</p>
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ activeProjectsCount }}</p>
</div>
</div>
<!-- Goal Progress -->
<div v-if="goalProgress" class="mt-6">
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-3">Goals</h2>
<div class="grid grid-cols-3 gap-4">
<div>
<div class="flex items-center justify-between mb-1">
<span class="text-[0.75rem] text-text-secondary">Today</span>
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatGoalHours(goalProgress.today_seconds) }}</span>
</div>
<div class="w-full bg-bg-elevated rounded-full h-1.5">
<div
class="h-1.5 rounded-full bg-accent transition-all"
:style="{ width: Math.min(dailyPct, 100) + '%' }"
/>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<span class="text-[0.75rem] text-text-secondary">This Week</span>
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatGoalHours(goalProgress.week_seconds) }}</span>
</div>
<div class="w-full bg-bg-elevated rounded-full h-1.5">
<div
class="h-1.5 rounded-full bg-accent transition-all"
:style="{ width: Math.min(weeklyPct, 100) + '%' }"
/>
</div>
</div>
<div class="flex flex-col items-center justify-center">
<span class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ goalProgress.streak_days }}</span>
<span class="text-[0.6875rem] text-text-tertiary">day streak</span>
</div>
</div>
</div>
<!-- Weekly chart -->
<div class="mb-8">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Weekly Hours</h2>
<div class="h-48">
<Bar v-if="chartData" :data="chartData" :options="chartOptions" />
</div>
</div>
<!-- Recent entries -->
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary">Recent Entries</h2>
<router-link 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 in recentEntries"
:key="entry.id"
class="flex items-center justify-between py-3 border-b border-border-subtle last:border-0"
>
<div class="flex items-center gap-3">
<div
class="w-2 h-2 rounded-full shrink-0"
:style="{ backgroundColor: getProjectColor(entry.project_id) }"
/>
<span class="text-[0.75rem] text-text-primary">{{ getProjectName(entry.project_id) }}</span>
</div>
<span class="text-[0.75rem] font-mono text-text-secondary">{{ formatDuration(entry.duration) }}</span>
</div>
</div>
<p v-else class="text-[0.75rem] text-text-tertiary py-8">
No entries yet. Start tracking your time.
</p>
</div>
<!-- Budget Alerts -->
<div v-if="budgetAlerts.length > 0" class="mt-6">
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-3">Budget Alerts</h2>
<div class="space-y-2">
<div
v-for="alert in budgetAlerts"
:key="alert.id"
class="flex items-center justify-between py-2 px-3 rounded-lg"
:class="alert.pct > 90 ? 'bg-status-error/10' : 'bg-status-warning/10'"
>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :style="{ backgroundColor: alert.color }" />
<span class="text-[0.75rem] text-text-primary">{{ alert.name }}</span>
</div>
<span class="text-[0.75rem] font-mono" :class="alert.pct > 90 ? 'text-status-error' : 'text-status-warning'">
{{ alert.pct.toFixed(0) }}%
</span>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js'
import { Clock } from 'lucide-vue-next'
import { useEntriesStore } from '../stores/entries'
import { useProjectsStore } from '../stores/projects'
import { useSettingsStore } from '../stores/settings'
import { formatDateLong } from '../utils/locale'
import type { TimeEntry } from '../stores/entries'
// Register Chart.js components
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const entriesStore = useEntriesStore()
const projectsStore = useProjectsStore()
const settingsStore = useSettingsStore()
const goalProgress = ref<{ today_seconds: number; week_seconds: number; streak_days: number } | null>(null)
async function loadGoalProgress() {
try {
goalProgress.value = await invoke('get_goal_progress')
} catch (e) {
// ignore
}
}
const dailyGoalHours = computed(() => parseFloat(settingsStore.settings.daily_goal_hours) || 8)
const weeklyGoalHours = computed(() => parseFloat(settingsStore.settings.weekly_goal_hours) || 40)
const dailyPct = computed(() => goalProgress.value ? (goalProgress.value.today_seconds / 3600 / dailyGoalHours.value) * 100 : 0)
const weeklyPct = computed(() => goalProgress.value ? (goalProgress.value.week_seconds / 3600 / weeklyGoalHours.value) * 100 : 0)
function formatGoalHours(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return `${h}h ${m}m`
}
const todayStats = ref<{ totalSeconds: number; byProject: unknown[] }>({ totalSeconds: 0, byProject: [] })
const weekStats = ref<{ totalSeconds: number; byProject: unknown[] }>({ totalSeconds: 0, byProject: [] })
const monthStats = ref<{ totalSeconds: number; byProject: unknown[] }>({ totalSeconds: 0, byProject: [] })
const recentEntries = ref<TimeEntry[]>([])
// Greeting based on time of day
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 12) return 'Good morning'
if (hour < 18) return 'Good afternoon'
return 'Good evening'
})
// Formatted date
const formattedDate = computed(() => {
return formatDateLong(new Date().toISOString())
})
// Empty state check
const isEmpty = computed(() => {
return recentEntries.value.length === 0 && weekStats.value.totalSeconds === 0
})
// Get start of current week (Monday)
function getWeekStart(): string {
const now = new Date()
const day = now.getDay()
const diff = now.getDate() - day + (day === 0 ? -6 : 1)
const monday = new Date(now.setDate(diff))
return monday.toISOString().split('T')[0]
}
// Get start of current month
function getMonthStart(): string {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
}
// Get today's date
function getToday(): string {
return new Date().toISOString().split('T')[0]
}
// 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`
}
// 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'
}
// Active projects count (non-archived)
const activeProjectsCount = computed(() => {
return projectsStore.projects.filter(p => !p.archived).length
})
// Budget status
const budgetStatus = ref<Record<number, { used_hours: number; used_amount: number }>>({})
async function loadBudgetStatus() {
for (const project of projectsStore.projects) {
if (project.id && project.budget_hours) {
try {
const status = await invoke<{ used_hours: number; used_amount: number }>('get_project_budget_status', { projectId: project.id })
budgetStatus.value[project.id] = status
} catch (e) {
// ignore
}
}
}
}
const budgetAlerts = computed(() => {
return projectsStore.projects
.filter(p => p.id && p.budget_hours)
.map(p => {
const used = budgetStatus.value[p.id!]?.used_hours || 0
const pct = (used / p.budget_hours!) * 100
return { id: p.id!, name: p.name, color: p.color, pct }
})
.filter(a => a.pct > 75)
.sort((a, b) => b.pct - a.pct)
})
// Chart data for weekly hours
const chartData = computed(() => {
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const weekStart = getWeekStart()
const today = getToday()
const todayIndex = new Date().getDay()
const todayArrayIndex = todayIndex === 0 ? 6 : todayIndex - 1
// Initialize hours for each day
const hoursPerDay = [0, 0, 0, 0, 0, 0, 0]
// Sum up hours from entries for this week
recentEntries.value.forEach(entry => {
const entryDate = new Date(entry.start_time).toISOString().split('T')[0]
if (entryDate >= weekStart && entryDate <= today) {
const dayIndex = new Date(entry.start_time).getDay()
const index = dayIndex === 0 ? 6 : dayIndex - 1
hoursPerDay[index] += entry.duration / 3600
}
})
// Color today's bar lighter amber
const colors = hoursPerDay.map((_, i) => i === todayArrayIndex ? '#FBBF24' : '#D97706')
return {
labels: days,
datasets: [
{
label: 'Hours',
data: hoursPerDay,
backgroundColor: colors,
borderRadius: 2
}
]
}
})
// Chart options
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: (context: { raw: unknown }) => {
const hours = context.raw as number
return `${hours.toFixed(1)} hours`
}
}
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: '#2E2E2A'
},
ticks: {
color: '#5A5A54'
}
},
x: {
grid: {
display: false
},
ticks: {
color: '#5A5A54'
}
}
}
}
// Load data on mount
onMounted(async () => {
await projectsStore.fetchProjects()
await entriesStore.fetchEntries()
await settingsStore.fetchSettings()
await loadGoalProgress()
await loadBudgetStatus()
try {
todayStats.value = await invoke('get_reports', {
startDate: getToday(),
endDate: getToday()
})
} catch (error) {
console.error('Failed to fetch today stats:', error)
}
try {
weekStats.value = await invoke('get_reports', {
startDate: getWeekStart(),
endDate: getToday()
})
} catch (error) {
console.error('Failed to fetch week stats:', error)
}
try {
monthStats.value = await invoke('get_reports', {
startDate: getMonthStart(),
endDate: getToday()
})
} catch (error) {
console.error('Failed to fetch month stats:', error)
}
recentEntries.value = entriesStore.entries.slice(0, 5)
})
</script>