refactor(dashboard): extract DateRangeFilter component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-13 11:09:38 +01:00
parent 8a31f46c88
commit be6d557622
2 changed files with 83 additions and 256 deletions

View file

@ -0,0 +1,60 @@
<script setup lang="ts">
import Button from '@/components/ui/Button.vue'
type Preset = 'today' | '7d' | '30d' | 'custom'
const props = defineProps<{
preset: Preset
customFrom: string
customTo: string
loading?: boolean
}>()
const emit = defineEmits<{
(e: 'update:preset', val: Preset): void
(e: 'update:customFrom', val: string): void
(e: 'update:customTo', val: string): void
(e: 'apply'): void
}>()
</script>
<template>
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-base font-semibold text-foreground flex-1 tracking-tight">Overview</h2>
<!-- Preset buttons -->
<div class="flex items-center rounded-lg border border-border overflow-hidden bg-muted/30">
<button
v-for="p in (['today', '7d', '30d', 'custom'] as const)"
:key="p"
:class="[
'px-3 py-1.5 text-xs font-medium transition-colors',
preset === p
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
]"
@click="emit('update:preset', p)"
>
{{ p === 'today' ? 'Today' : p === '7d' ? '7 days' : p === '30d' ? '30 days' : 'Custom' }}
</button>
</div>
<!-- Custom date range -->
<template v-if="preset === 'custom'">
<input
:value="customFrom"
type="date"
class="h-8 rounded-lg border border-input bg-muted/30 px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
@input="emit('update:customFrom', ($event.target as HTMLInputElement).value)"
/>
<span class="text-xs text-muted-foreground">to</span>
<input
:value="customTo"
type="date"
class="h-8 rounded-lg border border-input bg-muted/30 px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
@input="emit('update:customTo', ($event.target as HTMLInputElement).value)"
/>
<Button size="sm" :loading="loading" @click="emit('apply')">Apply</Button>
</template>
</div>
</template>

View file

@ -1,20 +1,21 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { RouterLink } from 'vue-router'
import { dashboardApi } from '@/api/endpoints/dashboard'
import KpiCard from '@/components/dashboard/KpiCard.vue'
import Card from '@/components/ui/Card.vue'
import CardHeader from '@/components/ui/CardHeader.vue'
import CardTitle from '@/components/ui/CardTitle.vue'
import CardContent from '@/components/ui/CardContent.vue'
import Progress from '@/components/ui/Progress.vue'
import Button from '@/components/ui/Button.vue'
import DateRangeFilter from '@/components/dashboard/DateRangeFilter.vue'
import KpiRow from '@/components/dashboard/KpiRow.vue'
import TimelineChart from '@/components/dashboard/TimelineChart.vue'
import ToolUsageList from '@/components/dashboard/ToolUsageList.vue'
import ProjectBreakdown from '@/components/dashboard/ProjectBreakdown.vue'
import { useTasksStore } from '@/stores/tasks'
import { useDevopsStore } from '@/stores/devops'
import { formatDuration, formatDate, isoDateStr } from '@/lib/utils'
import type { KpiSummary, ProjectSummary, MonthlyDataPoint, DailyPoint, DowDataPoint, ToolUsage } from '@/types'
import { isoDateStr } from '@/lib/utils'
import type { KpiSummary, ProjectSummary, DailyPoint, DowDataPoint, ToolUsage } from '@/types'
const router = useRouter()
const tasksStore = useTasksStore()
const devopsStore = useDevopsStore()
@ -104,103 +105,24 @@ onMounted(async () => {
if (devopsStore.integration) devopsStore.fetchWorkItems()
} catch { /* no integration */ }
})
const maxMonthlyHours = computed(() => Math.max(...monthly.value.map((d) => d.hours), 1))
const maxDowHours = computed(() => Math.max(...dow.value.map((d) => d.hours), 1))
const maxToolPct = computed(() => Math.max(...tools.value.map((t) => t.pct), 1))
const progressColor = (pct: number | null) => {
if (!pct) return 'default'
if (pct > 90) return 'danger'
if (pct > 70) return 'warning'
return 'success'
}
const DOW_LABELS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header + Date filter -->
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-base font-semibold text-foreground flex-1 tracking-tight">Overview</h2>
<DateRangeFilter
:preset="preset"
:customFrom="customFrom"
:customTo="customTo"
:loading="loading"
@update:preset="preset = $event"
@update:customFrom="customFrom = $event"
@update:customTo="customTo = $event"
@apply="loadData"
/>
<!-- Preset buttons -->
<div class="flex items-center rounded-lg border border-border overflow-hidden bg-muted/30">
<button
v-for="p in (['today', '7d', '30d', 'custom'] as const)"
:key="p"
:class="[
'px-3 py-1.5 text-xs font-medium transition-colors',
preset === p
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
]"
@click="preset = p"
>
{{ p === 'today' ? 'Today' : p === '7d' ? '7 days' : p === '30d' ? '30 days' : 'Custom' }}
</button>
</div>
<!-- Custom date range -->
<template v-if="preset === 'custom'">
<input
v-model="customFrom"
type="date"
class="h-8 rounded-lg border border-input bg-muted/30 px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
<span class="text-xs text-muted-foreground">to</span>
<input
v-model="customTo"
type="date"
class="h-8 rounded-lg border border-input bg-muted/30 px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
<Button size="sm" :loading="loading" @click="loadData">Apply</Button>
</template>
</div>
<!-- KPI cards hero first card -->
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
<KpiCard
label="Total Hours"
:value="summary ? formatDuration(summary.total_hours) : '—'"
icon="clock"
:loading="loading"
:hero="true"
/>
<KpiCard
label="Working Days"
:value="summary?.working_days ?? '—'"
icon="calendar"
:loading="loading"
/>
<KpiCard
label="Projects"
:value="summary?.total_projects ?? '—'"
icon="folder"
:loading="loading"
to="/projects"
/>
<KpiCard
label="Avg / Day"
:value="summary ? formatDuration(summary.avg_hours_per_day) : '—'"
icon="trending-up"
:loading="loading"
/>
<KpiCard
label="Top Project"
:value="summary?.top_project ?? '—'"
icon="star"
:loading="loading"
to="/projects"
/>
<KpiCard
label="Commits"
:value="summary?.total_commits ?? '—'"
icon="git"
:loading="loading"
/>
</div>
<!-- KPI cards -->
<KpiRow :summary="summary" :loading="loading" />
<!-- Tasks Today + ADO row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
@ -299,167 +221,12 @@ const DOW_LABELS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
</div>
<!-- Charts row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Hours by Day bar chart -->
<Card class="border-border/60 bg-card panel-glow">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-semibold text-muted-foreground uppercase tracking-widest">Hours by Day</CardTitle>
</CardHeader>
<CardContent>
<!-- Loading skeleton -->
<div v-if="loading" class="h-40 flex items-end gap-px">
<div
v-for="i in 30" :key="i"
class="flex-1 bg-muted animate-pulse rounded-t"
:style="{ height: `${20 + Math.random() * 60}%` }"
/>
</div>
<!-- Empty state -->
<div v-else-if="monthly.length === 0" class="h-40 flex flex-col items-center justify-center gap-2">
<svg class="h-8 w-8 text-muted-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p class="text-xs text-muted-foreground">No sessions in this period</p>
</div>
<!-- Chart -->
<div v-else class="h-40 flex items-end gap-px overflow-hidden">
<div
v-for="point in monthly"
:key="point.date"
class="flex-1 bg-primary/70 hover:bg-primary rounded-t transition-colors duration-150 cursor-pointer"
:style="{ height: `${Math.max((point.hours / maxMonthlyHours) * 160, 2)}px` }"
:title="`${point.date}: ${formatDuration(point.hours)}`"
@click="router.push({ path: '/calendar', query: { date: point.date } })"
/>
</div>
</CardContent>
</Card>
<!-- Day of week bar chart -->
<Card class="border-border/60 bg-card panel-glow">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-semibold text-muted-foreground uppercase tracking-widest">By Day of Week</CardTitle>
</CardHeader>
<CardContent>
<!-- Loading skeleton -->
<div v-if="loading" class="h-40 flex items-end gap-2">
<div v-for="i in 7" :key="i" class="flex-1 flex flex-col items-center gap-1">
<div class="w-full bg-muted animate-pulse rounded-t" :style="{ height: `${30 + i * 8}%` }" />
<div class="h-3 w-4 bg-muted animate-pulse rounded" />
</div>
</div>
<!-- Empty state -->
<div v-else-if="dow.length === 0" class="h-40 flex flex-col items-center justify-center gap-2">
<svg class="h-8 w-8 text-muted-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p class="text-xs text-muted-foreground">No sessions in this period</p>
</div>
<!-- Chart -->
<div v-else class="flex items-end gap-2" style="height: 160px">
<div
v-for="point in dow"
:key="point.dow"
class="flex-1 flex flex-col items-center gap-1 cursor-default"
style="height: 160px; justify-content: flex-end"
>
<div
class="w-full bg-primary/70 hover:bg-primary rounded-t transition-colors duration-150"
:style="{ height: `${Math.max((point.hours / maxDowHours) * 128, 2)}px` }"
:title="`${point.label}: ${formatDuration(point.hours)}`"
/>
<span class="text-[10px] text-muted-foreground font-medium">{{ point.label.slice(0, 2) }}</span>
</div>
</div>
</CardContent>
</Card>
</div>
<TimelineChart :daily="monthly" :dow="dow" :loading="loading" />
<!-- Bottom row: Tools + Projects -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Tool usage -->
<Card class="border-border/60 bg-card panel-glow">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-semibold text-muted-foreground uppercase tracking-widest">Tool Usage</CardTitle>
</CardHeader>
<CardContent>
<!-- Loading skeleton -->
<div v-if="loading" class="space-y-3">
<div v-for="i in 5" :key="i" class="flex items-center gap-2">
<div class="h-3 rounded bg-muted animate-pulse" :style="{ width: `${40 + i * 10}px` }" />
<div class="flex-1 h-2 bg-muted animate-pulse rounded-full" />
<div class="h-3 w-8 bg-muted animate-pulse rounded" />
</div>
</div>
<!-- Empty state -->
<div v-else-if="tools.length === 0" class="flex flex-col items-center justify-center py-8 gap-2">
<svg class="h-8 w-8 text-muted-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
</svg>
<p class="text-xs text-muted-foreground">No tool data yet</p>
</div>
<!-- List -->
<div v-else class="space-y-2.5">
<div v-for="tool in tools.slice(0, 8)" :key="tool.tool" class="flex items-center gap-2.5">
<span class="text-xs text-foreground w-24 truncate shrink-0 tabular-nums">{{ tool.tool }}</span>
<div class="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div
class="h-full bg-primary/70 rounded-full transition-all duration-300"
:style="{ width: `${(tool.pct / maxToolPct) * 100}%` }"
/>
</div>
<span class="text-xs text-muted-foreground w-9 text-right shrink-0 tabular-nums">
{{ (tool.pct ?? 0).toFixed(0) }}%
</span>
</div>
</div>
</CardContent>
</Card>
<!-- Project hours table -->
<Card class="border-border/60 bg-card panel-glow">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-semibold text-muted-foreground uppercase tracking-widest">Projects</CardTitle>
</CardHeader>
<CardContent>
<!-- Loading skeleton -->
<div v-if="loading" class="space-y-3">
<div v-for="i in 5" :key="i" class="space-y-1.5">
<div class="flex justify-between">
<div class="h-3 rounded bg-muted animate-pulse" :style="{ width: `${80 + i * 15}px` }" />
<div class="h-3 w-12 bg-muted animate-pulse rounded" />
</div>
<div class="h-1.5 bg-muted animate-pulse rounded-full" />
</div>
</div>
<!-- Empty state -->
<div v-else-if="projects.length === 0" class="flex flex-col items-center justify-center py-8 gap-2">
<svg class="h-8 w-8 text-muted-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
<p class="text-xs text-muted-foreground">No project data yet</p>
</div>
<!-- List -->
<div v-else class="space-y-2.5">
<RouterLink
v-for="proj in projects.slice(0, 8)"
:key="proj.project_id"
:to="`/projects/${proj.project_id}`"
class="block group"
>
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-foreground truncate max-w-[160px] font-medium group-hover:text-primary transition-colors">{{ proj.display_name }}</span>
<span class="text-muted-foreground shrink-0 tabular-nums ml-2">{{ formatDuration(proj.total_hours) }}</span>
</div>
<Progress
v-if="proj.progress_pct !== null"
:value="proj.progress_pct"
:color="progressColor(proj.progress_pct)"
/>
</RouterLink>
</div>
</CardContent>
</Card>
<ToolUsageList :data="tools" :loading="loading" />
<ProjectBreakdown :data="projects" :loading="loading" />
</div>
</div>
</template>