mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
refactor(stats): standardize chart empty states with shared component
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user