feat: weekly comparison indicators and sparklines on dashboard
This commit is contained in:
@@ -1,8 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<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 -->
|
<!-- Empty state -->
|
||||||
<div v-if="isEmpty" class="flex flex-col items-center justify-center py-16">
|
<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" />
|
<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-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>
|
<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">
|
<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 -->
|
<!-- Main content -->
|
||||||
<Transition name="fade" appear>
|
<Transition name="fade" appear>
|
||||||
<div v-if="!isEmpty">
|
<div v-if="!loading && !isEmpty">
|
||||||
<!-- Greeting header -->
|
<!-- Greeting header -->
|
||||||
<div class="mb-8">
|
<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>
|
<p class="text-xs text-text-tertiary mt-1">{{ formattedDate }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats row - 4 columns -->
|
<!-- 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>
|
<div>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Today</p>
|
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Today</dt>
|
||||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(todayStats.totalSeconds) }}</p>
|
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(todayStats.totalSeconds) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">This Week</p>
|
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">This Week</dt>
|
||||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(weekStats.totalSeconds) }}</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">This Month</p>
|
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">This Month</dt>
|
||||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(monthStats.totalSeconds) }}</p>
|
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(monthStats.totalSeconds) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Active Projects</p>
|
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Active Projects</dt>
|
||||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ activeProjectsCount }}</p>
|
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ activeProjectsCount }}</dd>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
<!-- Goal Progress -->
|
<!-- Goal Progress -->
|
||||||
<div v-if="goalProgress" class="mt-6">
|
<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] text-text-secondary">Today</span>
|
||||||
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatGoalHours(goalProgress.today_seconds) }}</span>
|
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatGoalHours(goalProgress.today_seconds) }}</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
class="h-1.5 rounded-full bg-accent progress-bar"
|
class="h-1.5 rounded-full bg-accent progress-bar"
|
||||||
:style="{ width: Math.min(dailyPct, 100) + '%' }"
|
: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] text-text-secondary">This Week</span>
|
||||||
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatGoalHours(goalProgress.week_seconds) }}</span>
|
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatGoalHours(goalProgress.week_seconds) }}</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
class="h-1.5 rounded-full bg-accent progress-bar"
|
class="h-1.5 rounded-full bg-accent progress-bar"
|
||||||
:style="{ width: Math.min(weeklyPct, 100) + '%' }"
|
:style="{ width: Math.min(weeklyPct, 100) + '%' }"
|
||||||
@@ -77,16 +109,35 @@
|
|||||||
<!-- Weekly chart -->
|
<!-- Weekly chart -->
|
||||||
<div class="mb-8">
|
<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>
|
<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" />
|
<Bar v-if="chartData" :data="chartData" :options="chartOptions" />
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Recent entries -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<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>
|
<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>
|
||||||
|
|
||||||
<div v-if="recentEntries.length > 0">
|
<div v-if="recentEntries.length > 0">
|
||||||
@@ -99,6 +150,7 @@
|
|||||||
<div
|
<div
|
||||||
class="w-2 h-2 rounded-full shrink-0"
|
class="w-2 h-2 rounded-full shrink-0"
|
||||||
:style="{ backgroundColor: getProjectColor(entry.project_id) }"
|
:style="{ backgroundColor: getProjectColor(entry.project_id) }"
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="text-[0.75rem] text-text-primary">{{ getProjectName(entry.project_id) }}</span>
|
<span class="text-[0.75rem] text-text-primary">{{ getProjectName(entry.project_id) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,12 +174,46 @@
|
|||||||
:class="alert.pct > 90 ? 'bg-status-error/10' : 'bg-status-warning/10'"
|
:class="alert.pct > 90 ? 'bg-status-error/10' : 'bg-status-warning/10'"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<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>
|
<span class="text-[0.75rem] text-text-primary">{{ alert.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[0.75rem] font-mono" :class="alert.pct > 90 ? 'text-status-error' : 'text-status-warning'">
|
<span class="text-[0.75rem] font-mono" :class="alert.pct > 90 ? 'text-status-error' : 'text-status-warning'">
|
||||||
{{ alert.pct.toFixed(0) }}%
|
{{ alert.pct.toFixed(0) }}%
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +223,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { invoke } from '@tauri-apps/api/core'
|
||||||
import { Bar } from 'vue-chartjs'
|
import { Bar } from 'vue-chartjs'
|
||||||
import {
|
import {
|
||||||
@@ -149,11 +235,14 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Legend
|
Legend
|
||||||
} from 'chart.js'
|
} 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 { useEntriesStore } from '../stores/entries'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { formatDateLong } from '../utils/locale'
|
import { formatDateLong } from '../utils/locale'
|
||||||
|
import { getChartTheme, buildBarChartOptions } from '../utils/chartTheme'
|
||||||
import type { TimeEntry } from '../stores/entries'
|
import type { TimeEntry } from '../stores/entries'
|
||||||
|
|
||||||
// Register Chart.js components
|
// Register Chart.js components
|
||||||
@@ -162,6 +251,9 @@ ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
|||||||
const entriesStore = useEntriesStore()
|
const entriesStore = useEntriesStore()
|
||||||
const projectsStore = useProjectsStore()
|
const projectsStore = useProjectsStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
const onboardingStore = useOnboardingStore()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
const goalProgress = ref<{ today_seconds: number; week_seconds: number; streak_days: number } | null>(null)
|
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 monthStats = ref<{ totalSeconds: number; byProject: unknown[] }>({ totalSeconds: 0, byProject: [] })
|
||||||
const recentEntries = ref<TimeEntry[]>([])
|
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
|
// Greeting based on time of day
|
||||||
const greeting = computed(() => {
|
const greeting = computed(() => {
|
||||||
const hour = new Date().getHours()
|
const hour = new Date().getHours()
|
||||||
@@ -227,6 +329,11 @@ function getToday(): string {
|
|||||||
return new Date().toISOString().split('T')[0]
|
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
|
// Format duration from seconds to readable format
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
const hours = Math.floor(seconds / 3600)
|
const hours = Math.floor(seconds / 3600)
|
||||||
@@ -255,13 +362,23 @@ const activeProjectsCount = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Budget status
|
// 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() {
|
async function loadBudgetStatus() {
|
||||||
for (const project of projectsStore.projects) {
|
for (const project of projectsStore.projects) {
|
||||||
if (project.id && project.budget_hours) {
|
if (project.id && project.budget_hours) {
|
||||||
try {
|
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
|
budgetStatus.value[project.id] = status
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -274,7 +391,7 @@ const budgetAlerts = computed(() => {
|
|||||||
return projectsStore.projects
|
return projectsStore.projects
|
||||||
.filter(p => p.id && p.budget_hours)
|
.filter(p => p.id && p.budget_hours)
|
||||||
.map(p => {
|
.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
|
const pct = (used / p.budget_hours!) * 100
|
||||||
return { id: p.id!, name: p.name, color: p.color, pct }
|
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)
|
.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
|
// Chart data for weekly hours
|
||||||
const chartData = computed(() => {
|
const chartData = computed(() => {
|
||||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
@@ -303,8 +474,8 @@ const chartData = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Color today's bar lighter amber
|
const theme = chartTheme.value
|
||||||
const colors = hoursPerDay.map((_, i) => i === todayArrayIndex ? '#FBBF24' : '#D97706')
|
const colors = hoursPerDay.map((_, i) => i === todayArrayIndex ? (theme.accentMuted || theme.accent) : theme.accent)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labels: days,
|
labels: days,
|
||||||
@@ -319,15 +490,23 @@ const chartData = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Chart options
|
// Chart options (theme-aware)
|
||||||
const chartOptions = {
|
const chartOptions = computed(() => {
|
||||||
responsive: true,
|
const theme = chartTheme.value
|
||||||
maintainAspectRatio: false,
|
const base = buildBarChartOptions(theme)
|
||||||
plugins: {
|
return {
|
||||||
legend: {
|
...base,
|
||||||
display: false
|
scales: {
|
||||||
|
...base.scales,
|
||||||
|
y: {
|
||||||
|
...base.scales.y,
|
||||||
|
beginAtZero: true,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
...base.plugins,
|
||||||
tooltip: {
|
tooltip: {
|
||||||
|
...base.plugins.tooltip,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (context: { raw: unknown }) => {
|
label: (context: { raw: unknown }) => {
|
||||||
const hours = context.raw as number
|
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
|
// Load data on mount
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await projectsStore.fetchProjects()
|
loading.value = true
|
||||||
await entriesStore.fetchEntries()
|
|
||||||
await settingsStore.fetchSettings()
|
await Promise.all([
|
||||||
await loadGoalProgress()
|
projectsStore.fetchProjects(),
|
||||||
|
entriesStore.fetchEntries(),
|
||||||
|
settingsStore.fetchSettings(),
|
||||||
|
loadGoalProgress(),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Budget status depends on projects being loaded
|
||||||
await loadBudgetStatus()
|
await loadBudgetStatus()
|
||||||
|
|
||||||
try {
|
// Fetch stats in parallel
|
||||||
todayStats.value = await invoke('get_reports', {
|
const [todayResult, weekResult, monthResult] = await Promise.allSettled([
|
||||||
|
invoke<{ totalSeconds: number; byProject: unknown[] }>('get_reports', {
|
||||||
startDate: getToday(),
|
startDate: getToday(),
|
||||||
endDate: getToday()
|
endDate: getToday()
|
||||||
})
|
}),
|
||||||
} catch (error) {
|
invoke<{ totalSeconds: number; byProject: unknown[] }>('get_reports', {
|
||||||
console.error('Failed to fetch today stats:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
weekStats.value = await invoke('get_reports', {
|
|
||||||
startDate: getWeekStart(),
|
startDate: getWeekStart(),
|
||||||
endDate: getToday()
|
endDate: getToday()
|
||||||
})
|
}),
|
||||||
} catch (error) {
|
invoke<{ totalSeconds: number; byProject: unknown[] }>('get_reports', {
|
||||||
console.error('Failed to fetch week stats:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
monthStats.value = await invoke('get_reports', {
|
|
||||||
startDate: getMonthStart(),
|
startDate: getMonthStart(),
|
||||||
endDate: getToday()
|
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)
|
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>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user