feat: weekly comparison indicators and sparklines on dashboard

This commit is contained in:
Your Name
2026-02-20 15:32:07 +02:00
parent a4fce3b7ab
commit ea2d0cba7f

View File

@@ -1,8 +1,30 @@
<template>
<div class="p-6">
<GettingStartedChecklist />
<!-- Loading skeleton -->
<div v-if="loading" class="space-y-6" aria-busy="true" aria-label="Loading dashboard data">
<div class="grid grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="bg-bg-surface rounded-lg p-4">
<div class="h-3 w-16 bg-bg-elevated rounded animate-pulse mb-2" />
<div class="h-6 w-24 bg-bg-elevated rounded animate-pulse" />
</div>
</div>
<div class="bg-bg-surface rounded-lg p-4">
<div class="h-48 bg-bg-elevated rounded animate-pulse" />
</div>
<div class="bg-bg-surface rounded-lg p-4 space-y-3">
<div v-for="i in 3" :key="i" class="h-10 bg-bg-elevated rounded animate-pulse" />
</div>
</div>
<div class="sr-only" aria-live="polite">
{{ loading ? '' : 'Dashboard loaded' }}
</div>
<!-- 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 animate-float" :stroke-width="1.5" />
<div v-if="!loading && isEmpty" class="flex flex-col items-center justify-center py-16">
<Clock class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" aria-hidden="true" />
<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">
@@ -12,32 +34,42 @@
<!-- Main content -->
<Transition name="fade" appear>
<div v-if="!isEmpty">
<div v-if="!loading && !isEmpty">
<!-- Greeting header -->
<div class="mb-8">
<p class="text-xl font-[family-name:var(--font-heading)] text-text-secondary">{{ greeting }}</p>
<h1 class="text-xl font-[family-name:var(--font-heading)] text-text-secondary">{{ greeting }}</h1>
<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">
<dl 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>
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Today</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(todayStats.totalSeconds) }}</dd>
</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>
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">This Week</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(weekStats.totalSeconds) }}</dd>
<div v-if="lastWeekSeconds > 0" class="flex items-center gap-1 mt-1">
<span class="sr-only">Compared to last week:</span>
<ChevronUp v-if="weekDiff > 0" class="w-3 h-3 text-status-running" aria-hidden="true" />
<ChevronDown v-else-if="weekDiff < 0" class="w-3 h-3 text-status-error" aria-hidden="true" />
<Minus v-else class="w-3 h-3 text-text-tertiary" aria-hidden="true" />
<span class="text-[0.625rem]"
:class="weekDiff >= 0 ? 'text-status-running' : 'text-status-error'">
{{ formatDuration(Math.abs(weekDiff)) }} {{ weekDiff >= 0 ? 'more' : 'less' }}
</span>
</div>
</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>
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">This Month</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(monthStats.totalSeconds) }}</dd>
</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>
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Active Projects</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ activeProjectsCount }}</dd>
</div>
</dl>
<!-- Goal Progress -->
<div v-if="goalProgress" class="mt-6">
@@ -48,7 +80,7 @@
<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="w-full bg-bg-elevated rounded-full h-1.5" role="progressbar" :aria-valuenow="Math.round(dailyPct)" aria-valuemin="0" aria-valuemax="100" :aria-label="'Daily goal progress: ' + Math.round(dailyPct) + '%'">
<div
class="h-1.5 rounded-full bg-accent progress-bar"
:style="{ width: Math.min(dailyPct, 100) + '%' }"
@@ -60,7 +92,7 @@
<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="w-full bg-bg-elevated rounded-full h-1.5" role="progressbar" :aria-valuenow="Math.round(weeklyPct)" aria-valuemin="0" aria-valuemax="100" :aria-label="'Weekly goal progress: ' + Math.round(weeklyPct) + '%'">
<div
class="h-1.5 rounded-full bg-accent progress-bar"
:style="{ width: Math.min(weeklyPct, 100) + '%' }"
@@ -77,16 +109,35 @@
<!-- 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">
<div class="h-48" aria-label="Weekly hours bar chart">
<Bar v-if="chartData" :data="chartData" :options="chartOptions" />
</div>
</div>
<!-- 4-week sparkline -->
<div v-if="weeklySparkline.length > 0" class="mb-8 bg-bg-surface rounded-lg p-4 border border-border-subtle">
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-3">Last 4 Weeks</h2>
<div class="flex items-end gap-3 h-16" role="img" :aria-label="sparklineLabel">
<div
v-for="(week, i) in weeklySparkline"
:key="i"
class="flex-1 flex flex-col items-center gap-1"
>
<div
class="w-full rounded-sm transition-all duration-300"
:class="i === weeklySparkline.length - 1 ? 'bg-accent' : 'bg-accent-muted'"
:style="{ height: `${week.percent}%`, minHeight: week.hours > 0 ? '4px' : '0px' }"
/>
<span class="text-[0.5625rem] text-text-tertiary">{{ week.label }}</span>
</div>
</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>
<router-link to="/entries" class="text-xs text-text-tertiary hover:text-text-secondary transition-colors" aria-label="View all time entries">View all</router-link>
</div>
<div v-if="recentEntries.length > 0">
@@ -99,6 +150,7 @@
<div
class="w-2 h-2 rounded-full shrink-0"
:style="{ backgroundColor: getProjectColor(entry.project_id) }"
aria-hidden="true"
/>
<span class="text-[0.75rem] text-text-primary">{{ getProjectName(entry.project_id) }}</span>
</div>
@@ -122,12 +174,46 @@
: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 }" />
<div class="w-2 h-2 rounded-full" :style="{ backgroundColor: alert.color }" aria-hidden="true" />
<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>
<span class="sr-only">{{ alert.pct > 90 ? 'Over budget' : 'Approaching budget limit' }}</span>
</div>
</div>
</div>
<!-- Project Forecasts -->
<div v-if="projectForecasts.length > 0" class="mt-6">
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-3">Project Forecasts</h2>
<div class="space-y-3">
<div
v-for="forecast in projectForecasts"
:key="forecast.id"
class="px-3 py-2.5 bg-bg-surface border border-border-subtle rounded-lg"
>
<div class="flex items-center justify-between mb-1.5">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :style="{ backgroundColor: forecast.color }" aria-hidden="true" />
<span class="text-[0.8125rem] text-text-primary">{{ forecast.name }}</span>
</div>
<span class="text-[0.6875rem] font-mono" :class="paceColor(forecast.pace)">
{{ forecast.paceLabel }}
</span>
</div>
<div class="flex items-center justify-between mb-1">
<span class="text-[0.625rem] text-text-tertiary">{{ forecast.hoursUsed.toFixed(0) }}h / {{ forecast.budgetHours }}h</span>
<span class="text-[0.625rem] text-text-tertiary">{{ forecast.dailyAvg.toFixed(1) }}h/day</span>
</div>
<div class="w-full bg-bg-elevated rounded-full h-1" role="progressbar" :aria-valuenow="Math.round(forecast.pct)" aria-valuemin="0" aria-valuemax="100">
<div
class="h-1 rounded-full progress-bar"
:class="forecast.pct > 90 ? 'bg-status-error' : forecast.pct > 75 ? 'bg-status-warning' : 'bg-accent'"
:style="{ width: Math.min(forecast.pct, 100) + '%' }"
/>
</div>
</div>
</div>
</div>
@@ -137,7 +223,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { Bar } from 'vue-chartjs'
import {
@@ -149,11 +235,14 @@ import {
Tooltip,
Legend
} from 'chart.js'
import { Clock } from 'lucide-vue-next'
import { Clock, ChevronUp, ChevronDown, Minus } from 'lucide-vue-next'
import GettingStartedChecklist from '../components/GettingStartedChecklist.vue'
import { useOnboardingStore } from '../stores/onboarding'
import { useEntriesStore } from '../stores/entries'
import { useProjectsStore } from '../stores/projects'
import { useSettingsStore } from '../stores/settings'
import { formatDateLong } from '../utils/locale'
import { getChartTheme, buildBarChartOptions } from '../utils/chartTheme'
import type { TimeEntry } from '../stores/entries'
// Register Chart.js components
@@ -162,6 +251,9 @@ ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const entriesStore = useEntriesStore()
const projectsStore = useProjectsStore()
const settingsStore = useSettingsStore()
const onboardingStore = useOnboardingStore()
const loading = ref(true)
const goalProgress = ref<{ today_seconds: number; week_seconds: number; streak_days: number } | null>(null)
@@ -189,6 +281,16 @@ const weekStats = ref<{ totalSeconds: number; byProject: unknown[] }>({ totalSec
const monthStats = ref<{ totalSeconds: number; byProject: unknown[] }>({ totalSeconds: 0, byProject: [] })
const recentEntries = ref<TimeEntry[]>([])
// Weekly comparison data
const lastWeekSeconds = ref(0)
const weekDiff = ref(0)
// 4-week sparkline data
const weeklySparkline = ref<Array<{ hours: number; percent: number; label: string }>>([])
const sparklineLabel = computed(() =>
weeklySparkline.value.map(w => `${w.label}: ${w.hours.toFixed(1)} hours`).join(', ')
)
// Greeting based on time of day
const greeting = computed(() => {
const hour = new Date().getHours()
@@ -227,6 +329,11 @@ function getToday(): string {
return new Date().toISOString().split('T')[0]
}
// Format a Date to YYYY-MM-DD
function formatDate(d: Date): string {
return d.toISOString().split('T')[0]
}
// Format duration from seconds to readable format
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600)
@@ -255,13 +362,23 @@ const activeProjectsCount = computed(() => {
})
// Budget status
const budgetStatus = ref<Record<number, { used_hours: number; used_amount: number }>>({})
interface DashboardBudgetStatus {
hours_used: number
amount_used: number
daily_average_hours: number
estimated_completion_days: number | null
hours_remaining: number | null
pace: string | null
}
const budgetStatus = ref<Record<number, DashboardBudgetStatus>>({})
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 })
const today = new Date().toISOString().split('T')[0]
const status = await invoke<DashboardBudgetStatus>('get_project_budget_status', { projectId: project.id, today })
budgetStatus.value[project.id] = status
} catch (e) {
// ignore
@@ -274,7 +391,7 @@ 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 used = budgetStatus.value[p.id!]?.hours_used || 0
const pct = (used / p.budget_hours!) * 100
return { id: p.id!, name: p.name, color: p.color, pct }
})
@@ -282,6 +399,60 @@ const budgetAlerts = computed(() => {
.sort((a, b) => b.pct - a.pct)
})
// Project forecasts
interface ForecastData {
id: number
name: string
color: string
hoursUsed: number
budgetHours: number
pct: number
dailyAvg: number
pace: string | null
paceLabel: string
}
const projectForecasts = computed<ForecastData[]>(() => {
return projectsStore.projects
.filter(p => p.id && p.budget_hours && budgetStatus.value[p.id!])
.map(p => {
const status = budgetStatus.value[p.id!]
const pct = p.budget_hours! > 0 ? (status.hours_used / p.budget_hours!) * 100 : 0
let paceLabel = 'No data'
if (status.pace === 'ahead') paceLabel = 'Ahead'
else if (status.pace === 'on_track') paceLabel = 'On track'
else if (status.pace === 'behind') paceLabel = 'Behind'
else if (status.pace === 'complete') paceLabel = 'Complete'
return {
id: p.id!,
name: p.name,
color: p.color,
hoursUsed: status.hours_used,
budgetHours: p.budget_hours!,
pct,
dailyAvg: status.daily_average_hours || 0,
pace: status.pace,
paceLabel,
}
})
.slice(0, 5)
})
function paceColor(pace: string | null): string {
if (pace === 'ahead') return 'text-status-running'
if (pace === 'on_track') return 'text-accent-text'
if (pace === 'behind') return 'text-status-error'
if (pace === 'complete') return 'text-status-running'
return 'text-text-tertiary'
}
// Chart theme state
const chartTheme = ref(getChartTheme())
function refreshChartTheme() {
chartTheme.value = getChartTheme()
}
// Chart data for weekly hours
const chartData = computed(() => {
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
@@ -303,8 +474,8 @@ const chartData = computed(() => {
}
})
// Color today's bar lighter amber
const colors = hoursPerDay.map((_, i) => i === todayArrayIndex ? '#FBBF24' : '#D97706')
const theme = chartTheme.value
const colors = hoursPerDay.map((_, i) => i === todayArrayIndex ? (theme.accentMuted || theme.accent) : theme.accent)
return {
labels: days,
@@ -319,15 +490,23 @@ const chartData = computed(() => {
}
})
// Chart options
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
// Chart options (theme-aware)
const chartOptions = computed(() => {
const theme = chartTheme.value
const base = buildBarChartOptions(theme)
return {
...base,
scales: {
...base.scales,
y: {
...base.scales.y,
beginAtZero: true,
},
},
plugins: {
...base.plugins,
tooltip: {
...base.plugins.tooltip,
callbacks: {
label: (context: { raw: unknown }) => {
const hours = context.raw as number
@@ -335,63 +514,105 @@ const chartOptions = {
}
}
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: '#2E2E2A'
},
ticks: {
color: '#5A5A54'
}
},
x: {
grid: {
display: false
},
ticks: {
color: '#5A5A54'
}
}
}
}
})
// Recreate chart on theme or accent change
watch(() => settingsStore.settings.theme_mode, () => {
nextTick(() => { refreshChartTheme() })
})
watch(() => settingsStore.settings.accent_color, () => {
nextTick(() => { refreshChartTheme() })
})
// Load data on mount
onMounted(async () => {
await projectsStore.fetchProjects()
await entriesStore.fetchEntries()
await settingsStore.fetchSettings()
await loadGoalProgress()
loading.value = true
await Promise.all([
projectsStore.fetchProjects(),
entriesStore.fetchEntries(),
settingsStore.fetchSettings(),
loadGoalProgress(),
])
// Budget status depends on projects being loaded
await loadBudgetStatus()
try {
todayStats.value = await invoke('get_reports', {
// Fetch stats in parallel
const [todayResult, weekResult, monthResult] = await Promise.allSettled([
invoke<{ totalSeconds: number; byProject: unknown[] }>('get_reports', {
startDate: getToday(),
endDate: getToday()
})
} catch (error) {
console.error('Failed to fetch today stats:', error)
}
try {
weekStats.value = await invoke('get_reports', {
}),
invoke<{ totalSeconds: number; byProject: unknown[] }>('get_reports', {
startDate: getWeekStart(),
endDate: getToday()
})
} catch (error) {
console.error('Failed to fetch week stats:', error)
}
try {
monthStats.value = await invoke('get_reports', {
}),
invoke<{ totalSeconds: number; byProject: unknown[] }>('get_reports', {
startDate: getMonthStart(),
endDate: getToday()
})
} catch (error) {
console.error('Failed to fetch month stats:', error)
}
}),
])
if (todayResult.status === 'fulfilled') todayStats.value = todayResult.value
if (weekResult.status === 'fulfilled') weekStats.value = weekResult.value
if (monthResult.status === 'fulfilled') monthStats.value = monthResult.value
recentEntries.value = entriesStore.entries.slice(0, 5)
// Fetch last week's data for comparison
const lastWeekEnd = new Date()
lastWeekEnd.setDate(lastWeekEnd.getDate() - lastWeekEnd.getDay() + (lastWeekEnd.getDay() === 0 ? -7 : 0))
const lastWeekStart = new Date(lastWeekEnd)
lastWeekStart.setDate(lastWeekStart.getDate() - 6)
try {
const lastWeek = await invoke<{ totalSeconds: number; byProject: unknown[] }>('get_reports', {
startDate: formatDate(lastWeekStart),
endDate: formatDate(lastWeekEnd)
})
lastWeekSeconds.value = lastWeek.totalSeconds || 0
weekDiff.value = weekStats.value.totalSeconds - lastWeekSeconds.value
} catch {
// Comparison data is optional
}
// Fetch sparkline data for last 4 weeks (including current)
const sparklineData: Array<{ hours: number; label: string }> = []
for (let w = 3; w >= 0; w--) {
const now = new Date()
const dayOfWeek = now.getDay()
const monday = new Date(now)
monday.setDate(now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1) - (w * 7))
const sunday = new Date(monday)
sunday.setDate(sunday.getDate() + 6)
const end = w === 0 ? now : sunday
try {
const report = await invoke<{ totalSeconds: number; byProject: unknown[] }>('get_reports', {
startDate: formatDate(monday),
endDate: formatDate(end)
})
sparklineData.push({
hours: (report.totalSeconds || 0) / 3600,
label: `${monday.getDate()}/${monday.getMonth() + 1}`
})
} catch {
sparklineData.push({ hours: 0, label: `${monday.getDate()}/${monday.getMonth() + 1}` })
}
}
const maxHours = Math.max(...sparklineData.map(d => d.hours), 1)
weeklySparkline.value = sparklineData.map(d => ({
hours: d.hours,
percent: (d.hours / maxHours) * 100,
label: d.label
}))
await onboardingStore.load()
// Refresh chart theme after settings are loaded
refreshChartTheme()
loading.value = false
})
</script>