refactor(stats): standardize chart empty states with shared component

This commit is contained in:
neon
2026-04-15 19:33:27 -06:00
parent cbaa3b0f1b
commit 586f5b080b
14 changed files with 124 additions and 56 deletions
@@ -3,6 +3,7 @@ import type { Component } from 'vue'
import { computed } from 'vue'
import { AlertCircle, GripVertical } from 'lucide-vue-next'
import { Skeleton } from '@/components/ui/skeleton'
import ChartEmptyState from './ChartEmptyState.vue'
const props = defineProps<{
title: string
@@ -10,6 +11,8 @@ const props = defineProps<{
colorIndex: number
loading: boolean
empty: boolean
emptyTitle?: string
emptyDescription?: string
unknownCount?: number
error?: boolean
}>()
@@ -53,16 +56,8 @@ const iconStyle = computed(() => {
<p class="text-sm">Failed to load data</p>
</div>
<div
v-else-if="empty"
class="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 animate-fade-up"
style="animation-delay: 100ms"
>
<component :is="icon" class="size-9 opacity-20" />
<div class="flex flex-col items-center gap-1">
<p class="text-sm font-medium">No data yet</p>
<p class="text-xs opacity-60">No data available for this chart</p>
</div>
<div v-else-if="empty" class="h-full">
<ChartEmptyState :icon="icon" :title="emptyTitle ?? 'No data yet'" :description="emptyDescription ?? 'No data available for this chart'" />
</div>
<slot v-else />
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { Component } from 'vue'
defineProps<{
icon: Component
title: string
description?: string
}>()
</script>
<template>
<div
class="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 text-center animate-fade-up"
style="animation-delay: 100ms"
>
<component :is="icon" class="size-9 opacity-20" />
<div class="flex max-w-[280px] flex-col items-center gap-1">
<p class="text-sm font-medium">{{ title }}</p>
<p v-if="description" class="text-xs opacity-70">{{ description }}</p>
</div>
</div>
</template>
@@ -5,6 +5,7 @@ import { BookCheck } from 'lucide-vue-next'
import { useUserBooksCompleted } from '../../composables/useUserBooksCompleted'
import ChartCard from '../ChartCard.vue'
import ChartEmptyState from '../ChartEmptyState.vue'
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const MIN_COMPLETIONS = 2
@@ -72,9 +73,12 @@ watchEffect(() => {
<template>
<ChartCard title="Books Completed" :icon="BookCheck" :color-index="3" :loading :error :empty="isEmpty">
<div v-if="!hasEnoughData" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Complete at least {{ MIN_COMPLETIONS }} books to see your curve
</div>
<ChartEmptyState
v-if="!hasEnoughData"
:icon="BookCheck"
title="Not enough data yet"
:description="`Need at least ${MIN_COMPLETIONS} completed books for this chart.`"
/>
<VChart v-else :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -5,6 +5,7 @@ import { Rabbit } from 'lucide-vue-next'
import { useUserCompletionLatency } from '../../composables/useUserCompletionLatency'
import ChartCard from '../ChartCard.vue'
import ChartEmptyState from '../ChartEmptyState.vue'
const MIN_COMPLETIONS = 5
@@ -59,9 +60,12 @@ function metric(value: number | null): string {
<template>
<ChartCard title="Completion Latency" :icon="Rabbit" :color-index="2" :loading :error :empty="isEmpty">
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_COMPLETIONS }} completions for reliable latency insights
</div>
<ChartEmptyState
v-if="lowConfidence"
:icon="Rabbit"
title="Not enough data yet"
:description="`Need at least ${MIN_COMPLETIONS} completed books for this chart.`"
/>
<div v-else class="flex h-full flex-col gap-3">
<div class="text-muted-foreground grid grid-cols-3 gap-2 text-xs">
<div class="rounded-md border px-2 py-1">
@@ -5,6 +5,7 @@ import { CalendarRange } from 'lucide-vue-next'
import { useUserCompletionTimeline } from '../../composables/useUserCompletionTimeline'
import ChartCard from '../ChartCard.vue'
import ChartEmptyState from '../ChartEmptyState.vue'
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const MIN_COMPLETIONS = 3
@@ -66,9 +67,12 @@ watchEffect(() => {
<template>
<ChartCard title="Completion Timeline" :icon="CalendarRange" :color-index="8" :loading :error :empty="isEmpty">
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_COMPLETIONS }} completed books for a reliable timeline
</div>
<ChartEmptyState
v-if="lowConfidence"
:icon="CalendarRange"
title="Not enough data yet"
:description="`Need at least ${MIN_COMPLETIONS} completed books for this chart.`"
/>
<VChart v-else :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -5,6 +5,7 @@ import { CalendarDays } from 'lucide-vue-next'
import { useUserFavoriteReadingDays } from '../../composables/useUserFavoriteReadingDays'
import ChartCard from '../ChartCard.vue'
import ChartEmptyState from '../ChartEmptyState.vue'
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const MIN_EVENTS = 14
@@ -83,9 +84,12 @@ watchEffect(() => {
<template>
<ChartCard title="Favorite Reading Days" :icon="CalendarDays" :color-index="6" :loading :error :empty="isEmpty">
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_EVENTS }} reading events for a stable weekday pattern
</div>
<ChartEmptyState
v-if="lowConfidence"
:icon="CalendarDays"
title="Not enough data yet"
:description="`Need at least ${MIN_EVENTS} reading events for this chart.`"
/>
<VChart v-else :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -7,6 +7,7 @@ import { useThemeStore } from '@/stores/theme'
import { getThemePalette, readCssColor } from '@/lib/echarts'
import { useUserGenreReadingTime } from '../../composables/useUserGenreReadingTime'
import ChartCard from '../ChartCard.vue'
import ChartEmptyState from '../ChartEmptyState.vue'
const MIN_GENRES = 2
@@ -75,9 +76,12 @@ watchEffect(() => {
<template>
<ChartCard title="Genre Reading Time" :icon="Tag" :color-index="6" :loading :error :empty="isEmpty">
<div v-if="!hasEnoughData" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_GENRES }} genres with reading time
</div>
<ChartEmptyState
v-if="!hasEnoughData"
:icon="Tag"
title="Not enough data yet"
:description="`Need at least ${MIN_GENRES} genres with reading time for this chart.`"
/>
<VChart v-else :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -5,6 +5,7 @@ import { Goal } from 'lucide-vue-next'
import { useUserGoalTrajectory } from '../../composables/useUserGoalTrajectory'
import ChartCard from '../ChartCard.vue'
import ChartEmptyState from '../ChartEmptyState.vue'
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const MIN_COMPLETIONS = 2
@@ -73,13 +74,18 @@ watchEffect(() => {
<template>
<ChartCard title="Pace vs Goal" :icon="Goal" :color-index="9" :loading :error :empty="isEmpty">
<div v-if="noCompletionsYet" class="text-muted-foreground flex h-full flex-col items-center justify-center gap-1 text-sm">
<p>No completed books in this window yet</p>
<p class="text-xs opacity-80">This chart plots completed books against your yearly goal.</p>
</div>
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_COMPLETIONS }} completions to show a stable trajectory
</div>
<VChart v-else-if="!noCompletionsYet" :option autoresize style="height: 100%" />
<ChartEmptyState
v-if="noCompletionsYet"
:icon="Goal"
title="No completed books yet"
description="Complete a book in this period to compare your pace against your yearly goal."
/>
<ChartEmptyState
v-else-if="lowConfidence"
:icon="Goal"
title="Not enough data yet"
:description="`Need at least ${MIN_COMPLETIONS} completed books for this chart.`"
/>
<VChart v-else :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -5,6 +5,7 @@ import { Clock3 } from 'lucide-vue-next'
import { useUserPeakReadingHours } from '../../composables/useUserPeakReadingHours'
import ChartCard from '../ChartCard.vue'
import ChartEmptyState from '../ChartEmptyState.vue'
const MIN_EVENTS = 20
@@ -69,9 +70,12 @@ watchEffect(() => {
<template>
<ChartCard title="Peak Reading Hours" :icon="Clock3" :color-index="5" :loading :error :empty="isEmpty">
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_EVENTS }} reading events for a stable hourly profile
</div>
<ChartEmptyState
v-if="lowConfidence"
:icon="Clock3"
title="Not enough data yet"
:description="`Need at least ${MIN_EVENTS} reading events for this chart.`"
/>
<VChart v-else :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -6,6 +6,7 @@ import { Waypoints } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useUserProgressFunnel } from '../../composables/useUserProgressFunnel'
import ChartCard from '../ChartCard.vue'
import ChartEmptyState from '../ChartEmptyState.vue'
type FunnelMode = 'percent' | 'counts' | 'dropoff'
const MODE_LABELS: Record<FunnelMode, string> = {
@@ -194,13 +195,18 @@ watchEffect(() => {
</DropdownMenu>
</template>
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_STARTED }} started books for a reliable funnel
</div>
<div v-else-if="showFlatState" class="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 text-sm">
<p>No observed drop-off in this window.</p>
<p class="text-xs opacity-80">Every started book progressed through all funnel stages.</p>
</div>
<ChartEmptyState
v-if="lowConfidence"
:icon="Waypoints"
title="Not enough data yet"
:description="`Need at least ${MIN_STARTED} started books for this chart.`"
/>
<ChartEmptyState
v-else-if="showFlatState"
:icon="Waypoints"
title="No drop-off observed"
description="Every started book progressed through all funnel stages in this period."
/>
<div v-else class="flex h-full min-h-0 flex-col gap-2.5">
<div class="flex flex-wrap gap-1.5 text-xs">
<span class="border-border bg-muted/20 rounded-md border px-2 py-1">
@@ -7,6 +7,7 @@ import { useThemeStore } from '@/stores/theme'
import { getThemePalette } from '@/lib/echarts'
import { useUserPeakReadingHours } from '../../composables/useUserPeakReadingHours'
import ChartCard from '../ChartCard.vue'
import ChartEmptyState from '../ChartEmptyState.vue'
// prettier-ignore
const HOUR_LABELS = [
@@ -119,9 +120,12 @@ watchEffect(() => {
<template>
<ChartCard title="Reading Clock" :icon="Clock" :color-index="5" :loading :error :empty="isEmpty">
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_EVENTS }} sessions for a stable clock
</div>
<ChartEmptyState
v-if="lowConfidence"
:icon="Clock"
title="Not enough data yet"
:description="`Need at least ${MIN_EVENTS} reading sessions for this chart.`"
/>
<template v-else>
<div v-if="peakHour" class="text-muted-foreground mb-1 text-center text-xs">
Peak: <span class="text-foreground font-medium">{{ HOUR_LABELS[peakHour.hour] }}</span>
@@ -7,6 +7,7 @@ import { useThemeStore } from '@/stores/theme'
import { buildHeatmapPalette } from '../../heatmap-palette'
import { useUserReadingHeatmap } from '../../composables/useUserReadingHeatmap'
import ChartCard from '../ChartCard.vue'
import ChartEmptyState from '../ChartEmptyState.vue'
const MIN_ACTIVE_DAYS = 5
@@ -135,9 +136,12 @@ watchEffect(() => {
<template>
<ChartCard title="Reading Heatmap" :icon="Calendar" :color-index="4" :loading :error :empty="isEmpty">
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need activity on at least {{ MIN_ACTIVE_DAYS }} days to show a reliable pattern
</div>
<ChartEmptyState
v-if="lowConfidence"
:icon="Calendar"
title="Not enough data yet"
:description="`Need activity on at least ${MIN_ACTIVE_DAYS} days for this chart.`"
/>
<div v-else class="h-full min-h-0 rounded-md px-2 py-2">
<VChart :option autoresize class="h-full w-full" />
</div>
@@ -5,6 +5,7 @@ import { Zap } from 'lucide-vue-next'
import { useUserReadingPace } from '../../composables/useUserReadingPace'
import ChartCard from '../ChartCard.vue'
import ChartEmptyState from '../ChartEmptyState.vue'
const MIN_SESSIONS = 10
@@ -60,9 +61,12 @@ watchEffect(() => {
<template>
<ChartCard title="Reading Pace" :icon="Zap" :color-index="2" :loading :error :empty="isEmpty">
<div v-if="!hasEnoughData" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_SESSIONS }} sessions with progress data
</div>
<ChartEmptyState
v-if="!hasEnoughData"
:icon="Zap"
title="Not enough data yet"
:description="`Need at least ${MIN_SESSIONS} sessions with progress data for this chart.`"
/>
<VChart v-else :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -9,6 +9,7 @@ import { getFormatColor } from '@/features/book/lib/format-colors'
import { fetchUserReadingSessionTimeline, updateUserReadingSessionTimelineSession } from '../../api/statistics.api'
import { useStatisticsConfig } from '../../composables/useStatisticsConfig'
import ChartCard from '../ChartCard.vue'
import ChartEmptyState from '../ChartEmptyState.vue'
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const
const MAX_TRACKS = 3
@@ -721,10 +722,12 @@ function getUtcIsoWeekStart(year: number, week: number): Date {
</p>
<div class="border-border/60 bg-muted/5 min-h-0 flex-1 rounded-lg border p-2">
<div v-if="isEmpty" class="text-muted-foreground flex h-full flex-col items-center justify-center gap-1 text-center">
<p class="text-sm font-medium">No data yet</p>
<p class="text-xs opacity-70">No sessions for this week. Use Prev/Next or week selector.</p>
</div>
<ChartEmptyState
v-if="isEmpty"
:icon="CalendarRange"
title="No sessions this week"
description="Use Prev/Next or the week selector to view a different week."
/>
<VChart
v-else